diff --git a/frontend/README.md b/frontend/README.md index b1100d5..e543726 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,10 +15,11 @@ frontend/ │ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning │ ├── history.js # Sources, lazy bounded history serialization, artifacts listing │ ├── orb.js # Vanilla canvas driver for the vendored thinking-orbs engine (loading view) +│ ├── queue.js # Pure admission-queue formatting helpers (ordinals, ETA, status line, Exit queue label) │ └── logo.js # Book logo playback: CSS cover flip on page open and brand hover -├── vendor/ # Pinned vendor libraries & licenses (locally served) ├── assets/ │ └── book.svg # Favicon (same book shape as the CSS logo) +├── vendor/ # Pinned vendor libraries & licenses (locally served) │ ├── marked.min.js │ ├── marked.LICENSE │ ├── purify.min.js @@ -30,10 +31,11 @@ frontend/ │ ├── scenario-toolbar.js # External dev toolbar script (CSP compliant, no inline scripts) │ └── scenario-toolbar.css # External dev toolbar styling (CSP compliant, no inline styles) ├── tests/ -│ ├── contract.test.js # Wire format, status code, header, & scenario tests (14 tests) +│ ├── contract.test.js # Wire format, status code, header, & scenario tests (23 tests, incl. the 5 admission queue scenarios) │ ├── api.test.js # UTF-8 byte boundary and credential validation tests (6 tests) +│ ├── queue.test.js # Admission queue formatting helpers: ordinals, ETA, status line, Exit queue label (12 tests) │ ├── render.test.js # Markdown section partitioning and fallback tests (10 tests) -│ └── e2e_runner.js # End-to-end browser test runner connecting to Chrome (9444) via CDP (16 tests) +│ └── e2e_runner.js # End-to-end browser test runner connecting to Chrome (9444) via CDP (19 tests) ├── package.json ├── package-lock.json ├── .gitignore @@ -62,6 +64,11 @@ frontend/ - Pathological blocks fall back to a bounded plain-text preview with full export available. - "Export to MD" always exports the complete, untouched raw Markdown client-side via Blob. - Tool call results in history are rendered lazily with bounded serialization buffers (`serializeBounded`). +5. **Admission Queue** (`docs/QUEUE_SPECIFICATION.md` §7): + - The backend runs one query at a time; submitting calls `POST /api/v1/queue/join` first. A `ready` answer sends the query immediately. A `queued` answer shows the loading view's queued sub-state (`shaping` orb, `js/orb.js`'s `setState`) with a status line such as "You're 3rd in line · about 4 min" and polls `GET /api/v1/queue/status` every 2 seconds until `ready`, then sends the query at once. + - The queued sub-state's "Exit queue" button (aria-label fixed as "Leave the queue") relabels itself by time waited — "I will try next time" (under 1 min), "Ohhh, it's so long" (1-3 min), "I'm dying in this queue" (over 3 min) — and calls `DELETE /api/v1/queue/ticket` on click, returning to the prompt view with the prompt text preserved. + - A `409 busy` right after `ready`, or a `404 ticket_not_found` while polling, triggers exactly one automatic rejoin (`js/api.js`'s `joinQueue`/`queueStatus`/`leaveQueue`); a second failure shows a user-facing message instead of retrying forever. + - A best-effort `DELETE /api/v1/queue/ticket` keepalive fetch fires on `pagehide` while the loading view is visible, so closing the tab releases the ticket promptly instead of waiting out the server's heartbeat timeout. ## Development & Testing @@ -76,11 +83,21 @@ npm run dev node dev/mock-server.js --port 5173 ``` -Open `http://127.0.0.1:5173/` in your browser. A floating dev toolbar in the bottom-right corner allows toggling between all 13 deterministic mock scenarios (e.g. normal shared example, 403 verify, 409 busy, 504 timeout, malicious content, large output, delayed cancellation). +Open `http://127.0.0.1:5173/` in your browser. A floating dev toolbar in the bottom-right corner allows toggling between all 18 deterministic mock scenarios (e.g. normal shared example, 403 verify, 409 busy, 504 timeout, malicious content, large output, delayed cancellation, and the 5 admission queue scenarios below). + +Admission queue scenarios (`docs/QUEUE_SPECIFICATION.md` §10), each with its own per-session poll counter that resets whenever the active scenario changes: + +| Scenario | Behaviour | +| --- | --- | +| `queued` | join answers position 3 with `eta_seconds` 150; each status poll decrements the position; the third poll answers `ready`; the query then succeeds | +| `queued_no_estimate` | as `queued` with `eta_seconds: null` throughout | +| `queue_full` | join answers `503 queue_full` | +| `reservation_lost` | join always answers `ready`; the first query after `ready` answers `409 busy`; the automatic rejoin then succeeds | +| `ticket_lost` | join answers `queued`; the second status poll answers `404 ticket_not_found`; the automatic rejoin answers `ready` | ### Running Unit & Contract Tests -Tests verify API limits, wire contracts, headers, cookies, and markdown partitioning (30 tests): +Tests verify API limits, wire contracts, headers, cookies, markdown partitioning, and the admission queue formatting/scenarios (51 tests): ```bash cd frontend @@ -89,7 +106,7 @@ npm test ### Running E2E Browser Tests -Runs comprehensive browser tests against Chrome on port 9444 via CDP (16 tests): +Runs comprehensive browser tests against Chrome on port 9444 via CDP, including the queued → ready → result flow, Exit queue label timing (clock-stubbed), and a reservation-lost rejoin (19 tests): ```bash cd frontend diff --git a/frontend/css/style.css b/frontend/css/style.css index 5c3c222..4660e44 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -414,6 +414,12 @@ body { border-color: var(--color-border-hover); } +/* Queued sub-state "Exit queue" button shares .cancel-btn's look; only one of the two is + ever shown at a time (queued vs. running), toggled in js/app.js. */ +.exit-queue-btn { + min-width: 200px; +} + /* State 4: Result View */ .view-result { width: 100%; diff --git a/frontend/dev/mock-server.js b/frontend/dev/mock-server.js index cb07929..9d89623 100644 --- a/frontend/dev/mock-server.js +++ b/frontend/dev/mock-server.js @@ -35,9 +35,45 @@ export const SCENARIOS = [ 'delayed_cancellation', 'unknown_expired_download', 'malicious_content', - 'large_output' + 'large_output', + 'queued', + 'queued_no_estimate', + 'queue_full', + 'reservation_lost', + 'ticket_lost' ]; +// Admission queue scenarios (docs/QUEUE_SPECIFICATION.md §10) that drive a canned join/status +// dance instead of the immediate "ready" every other scenario answers with. +const QUEUE_SCENARIOS = new Set(['queued', 'queued_no_estimate', 'queue_full', 'reservation_lost', 'ticket_lost']); +const QUEUE_RESERVATION_SECONDS = 45; + +// Per-session queue simulation state, keyed by cw_session cookie value. Reset whenever the +// scenario active for that session changes, so switching scenarios mid-flow never leaks stale +// poll/join counters into the new scenario. +const queueSessions = new Map(); + +/** + * Returns (creating or resetting as needed) the per-session queue simulation record. + * @param {string} sessionId + * @param {string} scenario + */ +function getQueueRecord(sessionId, scenario) { + let rec = queueSessions.get(sessionId); + if (!rec || rec.scenario !== scenario) { + rec = { scenario, ticketId: null, joinCount: 0, pollCount: 0, queryAttempt: 0 }; + queueSessions.set(sessionId, rec); + } + return rec; +} + +/** + * Generates an opaque ticket id in the shape used by the real backend (CONTRACTS §1). + */ +function makeTicketId() { + return `q_${crypto.randomBytes(6).toString('hex')}`; +} + /** * Standard Security Headers */ @@ -539,9 +575,10 @@ export function createMockServer() { // Session cookie: cw_session const cookies = parseCookies(req.headers.cookie); - if (!cookies.cw_session) { - const newSession = crypto.randomBytes(16).toString('hex'); - res.setHeader('Set-Cookie', `cw_session=${newSession}; Path=/; HttpOnly; SameSite=Strict`); + let sessionId = cookies.cw_session; + if (!sessionId) { + sessionId = crypto.randomBytes(16).toString('hex'); + res.setHeader('Set-Cookie', `cw_session=${sessionId}; Path=/; HttpOnly; SameSite=Strict`); } // Origin check for mutation requests @@ -597,6 +634,208 @@ export function createMockServer() { } } + // Admission Queue Endpoint: POST /api/v1/queue/join + if (pathname === '/api/v1/queue/join' && method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let data; + try { + data = body ? JSON.parse(body) : {}; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Invalid JSON body' } })); + return; + } + if (data && typeof data === 'object' && ('credentials' in data || 'pat' in data)) { + res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Queue endpoints do not accept credentials' } })); + return; + } + + const scenario = getScenarioForRequest(req, urlObj); + + if (scenario === 'queue_full') { + res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ error: { code: 'queue_full', message: 'The queue is full, please try later.' } })); + return; + } + + if (!QUEUE_SCENARIOS.has(scenario)) { + // Every other scenario answers join with an immediate reservation (spec §10). + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ + ticket_id: makeTicketId(), + status: 'ready', + position: 0, + ahead: 0, + eta_seconds: null, + reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS, + runner: 'reserved' + })); + return; + } + + const rec = getQueueRecord(sessionId, scenario); + rec.joinCount += 1; + + if (scenario === 'reservation_lost') { + rec.ticketId = rec.ticketId || makeTicketId(); + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ + ticket_id: rec.ticketId, + status: 'ready', + position: 0, + ahead: 0, + eta_seconds: null, + reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS, + runner: 'reserved' + })); + return; + } + + if (scenario === 'ticket_lost') { + rec.ticketId = makeTicketId(); + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + if (rec.joinCount === 1) { + rec.pollCount = 0; + res.end(JSON.stringify({ + ticket_id: rec.ticketId, + status: 'queued', + position: 2, + ahead: 1, + eta_seconds: 60, + reservation_expires_in_seconds: null, + runner: 'running' + })); + } else { + // The automatic rejoin after the ticket was lost (spec §10, §7.1 step 6). + res.end(JSON.stringify({ + ticket_id: rec.ticketId, + status: 'ready', + position: 0, + ahead: 0, + eta_seconds: null, + reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS, + runner: 'reserved' + })); + } + return; + } + + // 'queued' / 'queued_no_estimate': idempotent join, position decrements only on + // status polls (see GET /api/v1/queue/status below). + const noEstimate = scenario === 'queued_no_estimate'; + rec.ticketId = rec.ticketId || makeTicketId(); + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ + ticket_id: rec.ticketId, + status: 'queued', + position: 3, + ahead: 2, + eta_seconds: noEstimate ? null : 150, + reservation_expires_in_seconds: null, + runner: 'running' + })); + }); + return; + } + + // Admission Queue Endpoint: GET /api/v1/queue/status + if (pathname === '/api/v1/queue/status' && method === 'GET') { + const scenario = getScenarioForRequest(req, urlObj); + + if (!QUEUE_SCENARIOS.has(scenario) || scenario === 'queue_full' || scenario === 'reservation_lost') { + // These scenarios never leave a session polling: non-queue scenarios resolve at join, + // queue_full never creates a ticket, and reservation_lost resolves at join too. + res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ error: { code: 'ticket_not_found', message: 'No ticket for this session.' } })); + return; + } + + const rec = queueSessions.get(sessionId); + if (!rec || rec.scenario !== scenario || !rec.ticketId) { + res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ error: { code: 'ticket_not_found', message: 'No ticket for this session.' } })); + return; + } + + rec.pollCount += 1; + + // Build the response body first so exactly one writeHead/end pair is ever sent, + // regardless of which branch (200 queued/ready vs. 404 lost) fires. + let statusCode = 200; + let payload; + + if (scenario === 'ticket_lost') { + if (rec.pollCount === 1) { + payload = { + ticket_id: rec.ticketId, + status: 'queued', + position: 1, + ahead: 0, + eta_seconds: 30, + reservation_expires_in_seconds: null, + runner: 'running' + }; + } else { + // Second poll: the ticket has expired server-side (spec §10). + rec.ticketId = null; + statusCode = 404; + payload = { error: { code: 'ticket_not_found', message: 'Ticket expired.' } }; + } + } else { + // 'queued' / 'queued_no_estimate': decrement position on each poll; ready on the third. + const noEstimate = scenario === 'queued_no_estimate'; + if (rec.pollCount === 1) { + payload = { + ticket_id: rec.ticketId, + status: 'queued', + position: 2, + ahead: 1, + eta_seconds: noEstimate ? null : 95, + reservation_expires_in_seconds: null, + runner: 'running' + }; + } else if (rec.pollCount === 2) { + payload = { + ticket_id: rec.ticketId, + status: 'queued', + position: 1, + ahead: 0, + eta_seconds: noEstimate ? null : 40, + reservation_expires_in_seconds: null, + runner: 'running' + }; + } else { + payload = { + ticket_id: rec.ticketId, + status: 'ready', + position: 0, + ahead: 0, + eta_seconds: null, + reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS, + runner: 'reserved' + }; + } + } + + res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(payload)); + return; + } + + // Admission Queue Endpoint: DELETE /api/v1/queue/ticket + if (pathname === '/api/v1/queue/ticket' && method === 'DELETE') { + const rec = queueSessions.get(sessionId); + if (rec) { + rec.ticketId = null; + } + res.writeHead(204, { 'Cache-Control': 'no-store' }); + res.end(); + return; + } + // API Endpoint 1: POST /api/v1/auth/verify if (pathname === '/api/v1/auth/verify' && method === 'POST') { let body = ''; @@ -654,6 +893,19 @@ export function createMockServer() { const scenario = getScenarioForRequest(req, urlObj); + // Admission queue scenario: the first query after "ready" loses the reservation + // (simulating network trouble); the client's automatic rejoin-then-retry succeeds + // on the second attempt (spec §10). + if (scenario === 'reservation_lost') { + const rec = getQueueRecord(sessionId, scenario); + rec.queryAttempt += 1; + if (rec.queryAttempt === 1) { + res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ error: { code: 'busy', message: 'Reservation lost; rejoin the queue.' } })); + return; + } + } + // 409 Busy check if (scenario === '409_busy' || isQueryBusy) { res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); diff --git a/frontend/index.html b/frontend/index.html index 1efd141..e8eefb4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -64,8 +64,9 @@
-

Agent researching Confluence...

+

Agent researching Confluence...

+ diff --git a/frontend/js/api.js b/frontend/js/api.js index deb710b..c134aba 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -62,9 +62,11 @@ export function validateCredentials({ url, pat }) { /** * Parses and normalizes API error responses. * @param {Response} response + * @param {{ notFoundCode?: string }} [opts] Overrides the fallback code used for a bodyless/non-JSON 404 + * (queue endpoints use `ticket_not_found`; artifacts use `artifact_not_found`). * @returns {Promise} */ -async function parseApiError(response) { +async function parseApiError(response, { notFoundCode = 'artifact_not_found' } = {}) { let code = 'execution_failed'; let message = `Request failed with status ${response.status}`; @@ -111,13 +113,15 @@ async function parseApiError(response) { message = 'Request forbidden by server policy.'; } } else if (response.status === 404) { - code = 'artifact_not_found'; + code = notFoundCode; } else if (response.status === 409) { code = 'busy'; } else if (response.status === 413) { code = 'request_too_large'; } else if (response.status === 502) { code = 'upstream_failed'; + } else if (response.status === 503) { + code = 'queue_full'; } else if (response.status === 504) { code = 'query_timeout'; } @@ -200,6 +204,72 @@ export async function submitQuery({ prompt, credentials }, { signal } = {}) { return await response.json(); } +/** + * Joins the admission queue for the current session. Idempotent: if the session already + * holds a ticket, the same ticket is returned unchanged. Carries no credentials. + * @param {{ signal?: AbortSignal }} [options] + * @returns {Promise<{ ticket_id: string, status: 'ready'|'queued', position: number, ahead: number, + * eta_seconds: number|null, reservation_expires_in_seconds: number|null, runner: 'reserved'|'running' }>} + */ +export async function joinQueue({ signal } = {}) { + const response = await fetch('/api/v1/queue/join', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + cache: 'no-store', + body: JSON.stringify({}), + signal + }); + + if (!response.ok) { + throw await parseApiError(response, { notFoundCode: 'ticket_not_found' }); + } + + return await response.json(); +} + +/** + * Polls the current session's ticket status. Every call refreshes the server-side heartbeat. + * @param {{ signal?: AbortSignal }} [options] + * @returns {Promise} Same shape as {@link joinQueue}. + */ +export async function queueStatus({ signal } = {}) { + const response = await fetch('/api/v1/queue/status', { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store', + signal + }); + + if (!response.ok) { + throw await parseApiError(response, { notFoundCode: 'ticket_not_found' }); + } + + return await response.json(); +} + +/** + * Drops the current session's ticket, if any. Always succeeds (204) whether or not a ticket + * existed. Used both for the explicit "Exit queue" action and a best-effort call on `pagehide`. + * @param {{ keepalive?: boolean, signal?: AbortSignal }} [options] + * @returns {Promise} + */ +export async function leaveQueue({ keepalive = false, signal } = {}) { + const response = await fetch('/api/v1/queue/ticket', { + method: 'DELETE', + credentials: 'same-origin', + cache: 'no-store', + keepalive, + signal + }); + + if (!response.ok) { + throw await parseApiError(response, { notFoundCode: 'ticket_not_found' }); + } +} + /** * Downloads an artifact by its backend-issued ID. * @param {string} artifactId diff --git a/frontend/js/app.js b/frontend/js/app.js index fa1badc..3980dfa 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -3,11 +3,12 @@ * Credentials remain strictly in browser memory and are never persisted or logged. */ -import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials } from './api.js'; +import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials, joinQueue, queueStatus, leaveQueue } from './api.js'; import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js'; import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js'; import { mountThinkingOrb } from './orb.js'; import { initBookLogo } from './logo.js'; +import { QUEUE_POLL_INTERVAL_MS, formatQueuedStatus, formatExitQueueLabel } from './queue.js'; // Application memory state let committedCredentials = null; // { url: string, pat: string } | null @@ -18,6 +19,13 @@ let activeVerifyAbortController = null; let currentVerifyGeneration = 0; let currentResult = null; let lastSubmittedPrompt = ''; +let thinkingOrb = null; + +// Admission queue state (docs/QUEUE_SPECIFICATION.md §7) +let queuePollTimerId = null; +let queueWaitStartedAt = null; // Date.now() at the join response that first returned "queued" +let queueRejoinedAfterBusy = false; // rejoin-once guard for 409 busy right after "ready" +let queueRejoinedAfterLoss = false; // rejoin-once guard for 404 ticket_not_found while polling // DOM Elements let viewPrompt, viewLoading, viewResult; @@ -26,7 +34,7 @@ let keyBtn, credIndicator, credStatusSr; let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm; let credUrlInput, credPatInput, togglePatBtn, modalFeedback; let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred; -let cancelBtn, backBtn, exportBtn; +let cancelBtn, exitQueueBtn, loadingStatus, backBtn, exportBtn; let outputContent, sectionNav, resultWarnings; let artifactsSection, artifactsList, artifactsError; let historySection, historyToggleBtn, historyToggleTitle, historyContent; @@ -43,8 +51,9 @@ document.addEventListener('DOMContentLoaded', () => { viewLoading = document.getElementById('view-loading'); viewResult = document.getElementById('view-result'); - // Decorative animations (page is a fixed light theme, so the orb ink is pinned to light) - mountThinkingOrb(document.getElementById('thinking-orb'), { state: 'solving', size: 64, theme: 'light' }); + // Decorative animations (page is a fixed light theme, so the orb ink is pinned to light). + // Starts in the "solving" preset; switched to "shaping" while queued (see setLoadingQueuedUI). + thinkingOrb = mountThinkingOrb(document.getElementById('thinking-orb'), { state: 'solving', size: 64, theme: 'light' }); initBookLogo(document.getElementById('app-brand'), document.getElementById('brand-logo')); // Prompt View @@ -73,6 +82,8 @@ document.addEventListener('DOMContentLoaded', () => { // Loading & Result Controls cancelBtn = document.getElementById('cancel-btn'); + exitQueueBtn = document.getElementById('exit-queue-btn'); + loadingStatus = document.getElementById('loading-status'); backBtn = document.getElementById('back-btn'); exportBtn = document.getElementById('export-btn'); @@ -100,8 +111,18 @@ document.addEventListener('DOMContentLoaded', () => { // Wire events setupPromptEvents(); setupModalEvents(); + setupLoadingEvents(); setupResultEvents(); updateCredentialIndicator(); + + // Best-effort ticket release on tab close/navigation while a ticket may still be held + // (queued, reserved, or running). A keepalive DELETE beats nothing; if it does not land, + // the server's heartbeat/reservation timeout reclaims the ticket within 15s (spec §7.1). + window.addEventListener('pagehide', () => { + if (viewLoading && !viewLoading.classList.contains('hidden')) { + leaveQueue({ keepalive: true }).catch(() => {}); + } + }); }); /** @@ -140,7 +161,8 @@ function setupPromptEvents() { } /** - * Handles query submission. + * Handles query submission: validates locally, then runs the admission queue flow + * (join → queued/ready → send) per docs/QUEUE_SPECIFICATION.md §7.1. */ async function handleSubmitQuery() { const prompt = promptInput.value; @@ -159,11 +181,138 @@ async function handleSubmitQuery() { hidePromptError(); lastSubmittedPrompt = prompt; - // Transition to loading view + // Transition to loading view. Default optimistically to the running sub-state: most + // submissions are admitted immediately (join answers "ready"), and this avoids a flash + // of queued UI while the join request is still in flight. switchView('loading'); + setLoadingRunningUI(); const generation = ++currentRequestGeneration; activeAbortController = new AbortController(); + queueWaitStartedAt = null; + queueRejoinedAfterBusy = false; + queueRejoinedAfterLoss = false; + + await beginAdmission(generation, prompt); +} + +/** + * Joins the admission queue and dispatches to the queued or ready path. + * Also used to rejoin once after a lost reservation (409 after ready) or a lost ticket + * (404 while polling), per spec §7.1 steps 5-6. + * @param {number} generation + * @param {string} prompt + */ +async function beginAdmission(generation, prompt) { + let status; + try { + status = await joinQueue({ signal: activeAbortController.signal }); + } catch (err) { + if (generation !== currentRequestGeneration) return; + if (generation === currentRequestGeneration) activeAbortController = null; + if (err.name === 'AbortError') { + switchView('prompt'); + return; + } + switchView('prompt'); + showQueryError(err); + return; + } + + if (generation !== currentRequestGeneration) return; + await handleAdmissionStatus(generation, prompt, status); +} + +/** + * Reacts to a join/status response: sends the query immediately when "ready", otherwise + * enters/updates the queued sub-state and schedules the next poll. + * @param {number} generation + * @param {string} prompt + * @param {{ status: 'ready'|'queued' }} status + */ +async function handleAdmissionStatus(generation, prompt, status) { + if (status.status === 'ready') { + stopPolling(); + setLoadingSendingUI(); + await sendQuery(generation, prompt); + return; + } + + // queued + if (queueWaitStartedAt === null) { + queueWaitStartedAt = Date.now(); + } + setLoadingQueuedUI(status); + schedulePoll(generation, prompt); +} + +/** + * Polls ticket status every QUEUE_POLL_INTERVAL_MS while queued. + * @param {number} generation + * @param {string} prompt + */ +function schedulePoll(generation, prompt) { + stopPolling(); + queuePollTimerId = setTimeout(() => { + queuePollTimerId = null; + pollStatus(generation, prompt); + }, QUEUE_POLL_INTERVAL_MS); +} + +/** + * Clears any pending poll timer. Safe to call when nothing is scheduled. + */ +function stopPolling() { + if (queuePollTimerId !== null) { + clearTimeout(queuePollTimerId); + queuePollTimerId = null; + } +} + +/** + * Fetches ticket status once and reacts to it, including the rejoin-once-then-give-up + * handling for a lost ticket (spec §7.1 step 6, §8). + * @param {number} generation + * @param {string} prompt + */ +async function pollStatus(generation, prompt) { + if (generation !== currentRequestGeneration) return; + + let status; + try { + status = await queueStatus({ signal: activeAbortController.signal }); + } catch (err) { + if (generation !== currentRequestGeneration) return; + if (err.name === 'AbortError') return; + + if (err.code === 'ticket_not_found') { + if (!queueRejoinedAfterLoss) { + queueRejoinedAfterLoss = true; + await beginAdmission(generation, prompt); + return; + } + switchView('prompt'); + showPromptError('Your place in the queue was lost. Please submit again.'); + return; + } + + switchView('prompt'); + showQueryError(err); + return; + } + + if (generation !== currentRequestGeneration) return; + await handleAdmissionStatus(generation, prompt, status); +} + +/** + * Sends the actual query once the reservation is held ("ready"). Handles the + * rejoin-once-then-give-up path for a reservation lost right after "ready" (§7.1 step 5). + * @param {number} generation + * @param {string} prompt + */ +async function sendQuery(generation, prompt) { + setLoadingRunningUI(); try { const result = await submitQuery( @@ -191,23 +340,20 @@ async function handleSubmitQuery() { return; } + if (err.code === 'busy') { + if (!queueRejoinedAfterBusy) { + queueRejoinedAfterBusy = true; + await beginAdmission(generation, prompt); + return; + } + switchView('prompt'); + showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.'); + return; + } + // Switch back to prompt view and display error switchView('prompt'); - if (err.code === 'confluence_auth_failed') { - showPromptError('Confluence authentication failed. Please verify your URL and PAT in the credentials modal.'); - } else if (err.code === 'origin_denied') { - showPromptError('Request forbidden: Origin denied by server policy.'); - } else if (err.code === 'destination_denied') { - showPromptError('Request forbidden: Confluence destination URL is not permitted by server policy.'); - } else if (err.code === 'busy') { - showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.'); - } else if (err.code === 'query_timeout') { - showPromptError('The query timed out. The operation exceeded the allowed execution time.'); - } else if (err.code === 'request_too_large') { - showPromptError('The request exceeds the maximum allowed payload size.'); - } else { - showPromptError(`Query failed [${err.code || 'error'}]: ${err.message || 'An unexpected error occurred.'}`); - } + showQueryError(err); } finally { if (generation === currentRequestGeneration) { activeAbortController = null; @@ -215,6 +361,32 @@ async function handleSubmitQuery() { } } +/** + * Maps a query/queue API error to the prompt-view error message. + * @param {Error & { code?: string }} err + */ +function showQueryError(err) { + if (err.code === 'confluence_auth_failed') { + showPromptError('Confluence authentication failed. Please verify your URL and PAT in the credentials modal.'); + } else if (err.code === 'origin_denied') { + showPromptError('Request forbidden: Origin denied by server policy.'); + } else if (err.code === 'destination_denied') { + showPromptError('Request forbidden: Confluence destination URL is not permitted by server policy.'); + } else if (err.code === 'busy') { + showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.'); + } else if (err.code === 'query_timeout') { + showPromptError('The query timed out. The operation exceeded the allowed execution time.'); + } else if (err.code === 'request_too_large') { + showPromptError('The request exceeds the maximum allowed payload size.'); + } else if (err.code === 'queue_full') { + showPromptError('The queue is full, please try later.'); + } else if (err.code === 'ticket_not_found') { + showPromptError('Your place in the queue was lost. Please submit again.'); + } else { + showPromptError(`Query failed [${err.code || 'error'}]: ${err.message || 'An unexpected error occurred.'}`); + } +} + /** * Sets up credentials modal and actions. */ @@ -339,11 +511,13 @@ function setupModalEvents() { // Clear credentials btnClearCred.addEventListener('click', () => { abortActiveVerify(); - // If a query is active, abort it + // If a query (or an admission wait) is active, abort it and release any held ticket if (activeAbortController) { currentRequestGeneration++; + stopPolling(); activeAbortController.abort(); activeAbortController = null; + leaveQueue().catch(() => {}); switchView('prompt'); } @@ -421,19 +595,39 @@ function updateCredentialIndicator() { } /** - * Sets up result view buttons and interactions. + * Sets up the loading view's Cancel (running) and Exit queue (queued) buttons. */ -function setupResultEvents() { - // Cancel button during loading +function setupLoadingEvents() { + // Cancel button: running sub-state only (spec §7.4, unchanged). The server observes the + // aborted fetch, ends the run, and promotes the next ticket. cancelBtn.addEventListener('click', () => { + currentRequestGeneration++; + stopPolling(); if (activeAbortController) { - currentRequestGeneration++; activeAbortController.abort(); activeAbortController = null; } switchView('prompt'); }); + // Exit queue button: queued sub-state only (spec §7.3). Sends leave, stops polling, and + // returns to the prompt view with the prompt text preserved (the textarea is never cleared). + exitQueueBtn.addEventListener('click', () => { + currentRequestGeneration++; + stopPolling(); + if (activeAbortController) { + activeAbortController.abort(); + activeAbortController = null; + } + leaveQueue().catch(() => {}); + switchView('prompt'); + }); +} + +/** + * Sets up result view buttons and interactions. + */ +function setupResultEvents() { // Back to prompt button backBtn.addEventListener('click', () => { switchView('prompt'); @@ -508,6 +702,40 @@ function renderResultView(result) { historyContent.classList.add('hidden'); } +/** + * Applies the queued loading sub-state: shaping orb, position/estimate status line, and the + * Exit queue button whose label depends on time waited (spec §7.2, §7.3). + * @param {{ position: number, eta_seconds: number|null }} status + */ +function setLoadingQueuedUI(status) { + if (thinkingOrb) thinkingOrb.setState('shaping'); + loadingStatus.textContent = formatQueuedStatus(status); + cancelBtn.classList.add('hidden'); + exitQueueBtn.classList.remove('hidden'); + exitQueueBtn.textContent = formatExitQueueLabel(Date.now() - (queueWaitStartedAt ?? Date.now())); +} + +/** + * Applies the brief "ready → sending" loading sub-state: shaping orb, no button (spec §7.2). + */ +function setLoadingSendingUI() { + if (thinkingOrb) thinkingOrb.setState('shaping'); + loadingStatus.textContent = "Your turn, starting…"; + cancelBtn.classList.add('hidden'); + exitQueueBtn.classList.add('hidden'); +} + +/** + * Applies the running loading sub-state: solving orb, existing status text, Cancel button + * (spec §7.2). Also the optimistic default shown while the join request is in flight. + */ +function setLoadingRunningUI() { + if (thinkingOrb) thinkingOrb.setState('solving'); + loadingStatus.textContent = 'Agent researching Confluence...'; + exitQueueBtn.classList.add('hidden'); + cancelBtn.classList.remove('hidden'); +} + /** * Switches the active view. * @param {'prompt'|'loading'|'result'} viewName diff --git a/frontend/js/orb.js b/frontend/js/orb.js index ef0a76a..f98949a 100644 --- a/frontend/js/orb.js +++ b/frontend/js/orb.js @@ -16,12 +16,12 @@ const REDUCED_MOTION_FRAME = 0.6; * Mounts an animated orb on an existing . * @param {HTMLCanvasElement} canvas * @param {{ state?: string, size?: 64|20, theme?: 'light'|'dark', speed?: number }} [options] - * @returns {{ destroy: () => void } | null} + * @returns {{ destroy: () => void, setState: (state: string) => void } | null} */ export function mountThinkingOrb(canvas, options = {}) { if (!canvas || typeof canvas.getContext !== 'function') return null; - const state = options.state || 'working'; + let state = options.state || 'working'; const size = options.size === 20 ? 20 : 64; const dark = options.theme === 'dark'; const speedMultiplier = typeof options.speed === 'number' && options.speed > 0 ? options.speed : 1; @@ -35,9 +35,26 @@ export function mountThinkingOrb(canvas, options = {}) { const ctx = canvas.getContext('2d'); if (!ctx) return null; - const { mode, speed, opts } = resolvePreset(state, size); - const draw = MODE_DRAWS[mode]; - const clock = speed * speedMultiplier; + let { mode, speed, opts } = resolvePreset(state, size); + let draw = MODE_DRAWS[mode]; + let clock = speed * speedMultiplier; + + /** + * Re-resolves the preset for a new state in place, without tearing down the + * IntersectionObserver / visibilitychange wiring. A no-op if the state is unchanged. + * @param {string} nextState + */ + const applyState = (nextState) => { + if (!nextState || nextState === state) return; + state = nextState; + ({ mode, speed, opts } = resolvePreset(state, size)); + draw = MODE_DRAWS[mode]; + clock = speed * speedMultiplier; + }; + // Mirrors the current preset name on the canvas as a plain data attribute (no inline style/ + // script involved) so tests can assert the orb's sub-state without reaching into this closure. + canvas.dataset.orbState = state; + const setStateAttr = () => { canvas.dataset.orbState = state; }; const paintAt = (t) => { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); @@ -48,7 +65,14 @@ export function mountThinkingOrb(canvas, options = {}) { const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; if (reducedMotion) { paintAt(REDUCED_MOTION_FRAME); - return { destroy() {} }; + return { + destroy() {}, + setState(nextState) { + applyState(nextState); + setStateAttr(); + paintAt(REDUCED_MOTION_FRAME); + } + }; } let frameId = 0; @@ -92,6 +116,16 @@ export function mountThinkingOrb(canvas, options = {}) { stop(); if (observer) observer.disconnect(); document.removeEventListener('visibilitychange', onVisibilityChange); + }, + /** + * Switches the orb to a different preset (e.g. `shaping` while queued, `solving` while + * running) without re-creating the visibility/intersection observers. + * @param {string} nextState + */ + setState(nextState) { + applyState(nextState); + setStateAttr(); + paintAt((performance.now() / 1000) * clock); } }; } diff --git a/frontend/js/queue.js b/frontend/js/queue.js new file mode 100644 index 0000000..9b33624 --- /dev/null +++ b/frontend/js/queue.js @@ -0,0 +1,60 @@ +/** + * Pure formatting helpers for the admission queue UI (spec `docs/QUEUE_SPECIFICATION.md` §7.2, §7.3). + * Deliberately framework- and DOM-free so they can be unit tested without a browser; the DOM + * wiring and polling state machine live in app.js. + */ + +export const QUEUE_POLL_INTERVAL_MS = 2000; + +/** + * English ordinal suffix for a 1-based rank: 1st, 2nd, 3rd, 4th, 11th-13th, 21st, ... + * @param {number} n + * @returns {string} + */ +export function ordinal(n) { + const rem100 = n % 100; + if (rem100 >= 11 && rem100 <= 13) return `${n}th`; + switch (n % 10) { + case 1: return `${n}st`; + case 2: return `${n}nd`; + case 3: return `${n}rd`; + default: return `${n}th`; + } +} + +/** + * Formats the estimate clause: "estimating…" when unknown, "under a minute" below 60s, + * otherwise "about N min" rounded up (spec §5, §7.2). + * @param {number|null|undefined} etaSeconds + * @returns {string} + */ +export function formatEta(etaSeconds) { + if (etaSeconds === null || etaSeconds === undefined) return 'estimating…'; + if (etaSeconds < 60) return 'under a minute'; + const minutes = Math.ceil(etaSeconds / 60); + return `about ${minutes} min`; +} + +/** + * Formats the queued sub-state status line (spec §7.2 table, `queued` row). + * Position 1 reads as "You're next" instead of "You're 1st in line". + * @param {{ position: number, eta_seconds: number|null }} status + * @returns {string} + */ +export function formatQueuedStatus({ position, eta_seconds: etaSeconds }) { + const place = position === 1 ? "You're next" : `You're ${ordinal(position)} in line`; + return `${place} · ${formatEta(etaSeconds)}`; +} + +/** + * Formats the "Exit queue" button's visible label based on time waited since the join + * response (spec §7.3). The button's `aria-label` stays "Leave the queue" regardless. + * @param {number} waitedMs + * @returns {string} + */ +export function formatExitQueueLabel(waitedMs) { + const waitedMinutes = waitedMs / 60000; + if (waitedMinutes < 1) return 'I will try next time'; + if (waitedMinutes <= 3) return "Ohhh, it's so long"; + return "I'm dying in this queue"; +} diff --git a/frontend/tests/contract.test.js b/frontend/tests/contract.test.js index b2b77ef..f305d0a 100644 --- a/frontend/tests/contract.test.js +++ b/frontend/tests/contract.test.js @@ -281,6 +281,211 @@ describe('Mock Server and Wire Contract Tests', () => { assert.equal(body.error.code, 'origin_denied'); }); + /** + * Extracts `cw_session=...` from a Set-Cookie header so subsequent requests in the same + * test can carry the session forward (raw fetch has no cookie jar). + */ + function extractSessionCookie(res, previous) { + const setCookie = res.headers.get('set-cookie'); + if (!setCookie) return previous; + const match = setCookie.match(/cw_session=[^;]+/); + return match ? match[0] : previous; + } + + describe('Admission queue scenarios (docs/QUEUE_SPECIFICATION.md §10)', () => { + test('queue_full: join answers 503 queue_full', async () => { + const res = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=queue_full`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL }, + body: '{}' + }); + assert.equal(res.status, 503); + assert.equal(res.headers.get('cache-control'), 'no-store'); + const body = await res.json(); + assert.equal(body.error.code, 'queue_full'); + }); + + test('queued: position 3 with eta, decrements on each poll, ready on the third, query succeeds', async () => { + let cookie; + const joinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=queued`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL }, + body: '{}' + }); + cookie = extractSessionCookie(joinRes, cookie); + assert.equal(joinRes.status, 200); + assert.equal(joinRes.headers.get('cache-control'), 'no-store'); + const joinBody = await joinRes.json(); + assert.equal(joinBody.status, 'queued'); + assert.equal(joinBody.position, 3); + assert.equal(joinBody.ahead, 2); + assert.equal(joinBody.eta_seconds, 150); + assert.ok(joinBody.ticket_id); + + const poll1 = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued`, { + headers: { Cookie: cookie } + }); + const poll1Body = await poll1.json(); + assert.equal(poll1Body.status, 'queued'); + assert.equal(poll1Body.position, 2); + assert.equal(poll1Body.ticket_id, joinBody.ticket_id); + + const poll2 = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued`, { + headers: { Cookie: cookie } + }); + const poll2Body = await poll2.json(); + assert.equal(poll2Body.status, 'queued'); + assert.equal(poll2Body.position, 1); + + const poll3 = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued`, { + headers: { Cookie: cookie } + }); + const poll3Body = await poll3.json(); + assert.equal(poll3Body.status, 'ready'); + assert.ok(poll3Body.reservation_expires_in_seconds > 0); + + const queryRes = await fetch(`${BASE_URL}/api/v1/query?scenario=queued`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie }, + body: JSON.stringify({ prompt: 'deploy service X', credentials: { url: 'https://example.com', pat: 'dummy' } }) + }); + assert.equal(queryRes.status, 200); + const queryBody = await queryRes.json(); + assert.ok(queryBody.markdown.includes('Deployment Guide')); + }); + + test('queued_no_estimate: eta_seconds is null at every step', async () => { + let cookie; + const joinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=queued_no_estimate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL }, + body: '{}' + }); + cookie = extractSessionCookie(joinRes, cookie); + const joinBody = await joinRes.json(); + assert.equal(joinBody.status, 'queued'); + assert.equal(joinBody.eta_seconds, null); + + const poll1Body = await (await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued_no_estimate`, { headers: { Cookie: cookie } })).json(); + assert.equal(poll1Body.eta_seconds, null); + + const poll2Body = await (await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued_no_estimate`, { headers: { Cookie: cookie } })).json(); + assert.equal(poll2Body.eta_seconds, null); + + const poll3Body = await (await fetch(`${BASE_URL}/api/v1/queue/status?scenario=queued_no_estimate`, { headers: { Cookie: cookie } })).json(); + assert.equal(poll3Body.status, 'ready'); + }); + + test('reservation_lost: query 409s once after ready, rejoin succeeds, second query succeeds', async () => { + let cookie; + const joinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=reservation_lost`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL }, + body: '{}' + }); + cookie = extractSessionCookie(joinRes, cookie); + const joinBody = await joinRes.json(); + assert.equal(joinBody.status, 'ready'); + + const firstQuery = await fetch(`${BASE_URL}/api/v1/query?scenario=reservation_lost`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie }, + body: JSON.stringify({ prompt: 'p', credentials: { url: 'https://example.com', pat: 'dummy' } }) + }); + assert.equal(firstQuery.status, 409); + const firstQueryBody = await firstQuery.json(); + assert.equal(firstQueryBody.error.code, 'busy'); + + const rejoinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=reservation_lost`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie }, + body: '{}' + }); + const rejoinBody = await rejoinRes.json(); + assert.equal(rejoinBody.status, 'ready'); + + const secondQuery = await fetch(`${BASE_URL}/api/v1/query?scenario=reservation_lost`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie }, + body: JSON.stringify({ prompt: 'p', credentials: { url: 'https://example.com', pat: 'dummy' } }) + }); + assert.equal(secondQuery.status, 200); + }); + + test('ticket_lost: second status poll answers 404, rejoin answers ready', async () => { + let cookie; + const joinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=ticket_lost`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL }, + body: '{}' + }); + cookie = extractSessionCookie(joinRes, cookie); + const joinBody = await joinRes.json(); + assert.equal(joinBody.status, 'queued'); + + const poll1Body = await (await fetch(`${BASE_URL}/api/v1/queue/status?scenario=ticket_lost`, { headers: { Cookie: cookie } })).json(); + assert.equal(poll1Body.status, 'queued'); + + const poll2 = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=ticket_lost`, { headers: { Cookie: cookie } }); + assert.equal(poll2.status, 404); + const poll2Body = await poll2.json(); + assert.equal(poll2Body.error.code, 'ticket_not_found'); + + const rejoinRes = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=ticket_lost`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL, Cookie: cookie }, + body: '{}' + }); + const rejoinBody = await rejoinRes.json(); + assert.equal(rejoinBody.status, 'ready'); + }); + + test('GET /api/v1/queue/status with no ticket answers 404 ticket_not_found', async () => { + const res = await fetch(`${BASE_URL}/api/v1/queue/status?scenario=normal`); + assert.equal(res.status, 404); + const body = await res.json(); + assert.equal(body.error.code, 'ticket_not_found'); + }); + + test('DELETE /api/v1/queue/ticket always answers 204', async () => { + const res = await fetch(`${BASE_URL}/api/v1/queue/ticket`, { + method: 'DELETE', + headers: { Origin: BASE_URL } + }); + assert.equal(res.status, 204); + assert.equal(res.headers.get('cache-control'), 'no-store'); + }); + + test('POST /api/v1/queue/join rejects a body containing credentials or pat', async () => { + const withCredentials = await fetch(`${BASE_URL}/api/v1/queue/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: BASE_URL }, + body: JSON.stringify({ credentials: { url: 'https://x', pat: 'y' } }) + }); + assert.equal(withCredentials.status, 400); + assert.equal((await withCredentials.json()).error.code, 'invalid_input'); + + const withPat = await fetch(`${BASE_URL}/api/v1/queue/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: BASE_URL }, + body: JSON.stringify({ pat: 'y' }) + }); + assert.equal(withPat.status, 400); + assert.equal((await withPat.json()).error.code, 'invalid_input'); + }); + + test('non-queue scenarios answer join with ready immediately (unchanged flows)', async () => { + const res = await fetch(`${BASE_URL}/api/v1/queue/join?scenario=normal`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: BASE_URL }, + body: '{}' + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.status, 'ready'); + }); + }); + test('Mock dev toolbar external assets served with correct headers and zero inline script/style', async () => { // CSS asset const cssRes = await fetch(`${BASE_URL}/dev/scenario-toolbar.css`); diff --git a/frontend/tests/e2e_runner.js b/frontend/tests/e2e_runner.js index 4c37a0e..b0710c8 100644 --- a/frontend/tests/e2e_runner.js +++ b/frontend/tests/e2e_runner.js @@ -557,7 +557,153 @@ async function runTests() { assert.equal(modalReopened, true, 'Modal should open when attempting query without credentials'); }); - // Test 16: Zero Automatic External Requests Network Assertion + // Test 16: Admission Queue - queued -> ready -> result (status text & orb state) + await step('Admission Queue: queued -> ready -> result', async () => { + // Credentials were cleared in the previous test; reconfigure them. + await cdp.eval('document.getElementById("key-btn").click()'); + await cdp.eval(` + document.getElementById("cred-url").value = "https://approved.example.com"; + document.getElementById("cred-pat").value = "dummy-pat-123"; + `); + await cdp.eval('document.getElementById("btn-save-cred").click()'); + await new Promise((r) => setTimeout(r, 100)); + + await cdp.eval(` + fetch("/dev/scenario", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenario: "queued" }) + }) + `); + + await cdp.eval('document.getElementById("prompt-input").value = "How do I deploy service X?"'); + await cdp.eval('document.getElementById("submit-btn").click()'); + + // Join answers position 3 / eta 150s ("about 3 min") immediately. + await new Promise((r) => setTimeout(r, 300)); + const joinState = await cdp.eval(`({ + status: document.getElementById("loading-status").textContent, + orb: document.getElementById("thinking-orb").dataset.orbState, + exitVisible: !document.getElementById("exit-queue-btn").classList.contains("hidden"), + cancelHidden: document.getElementById("cancel-btn").classList.contains("hidden"), + ariaLabel: document.getElementById("exit-queue-btn").getAttribute("aria-label") + })`); + assert.equal(joinState.status, "You're 3rd in line · about 3 min", `Unexpected queued status: ${joinState.status}`); + assert.equal(joinState.orb, 'shaping', 'Orb must be in the "shaping" preset while queued'); + assert.equal(joinState.exitVisible, true, 'Exit queue button must be visible while queued'); + assert.equal(joinState.cancelHidden, true, 'Cancel button must be hidden while queued'); + assert.equal(joinState.ariaLabel, 'Leave the queue', 'Exit queue aria-label must stay stable'); + + // First poll (~2s later): position 2 / eta 95s ("about 2 min"). + await new Promise((r) => setTimeout(r, 2200)); + const poll1Status = await cdp.eval('document.getElementById("loading-status").textContent'); + assert.equal(poll1Status, "You're 2nd in line · about 2 min", `Unexpected poll 1 status: ${poll1Status}`); + + // Second poll: position 1 / eta 40s ("under a minute"). + await new Promise((r) => setTimeout(r, 2200)); + const poll2Status = await cdp.eval('document.getElementById("loading-status").textContent'); + assert.equal(poll2Status, "You're next · under a minute", `Unexpected poll 2 status: ${poll2Status}`); + + // Third poll: ready -> query sent -> result. + await new Promise((r) => setTimeout(r, 2600)); + const resultOrbState = await cdp.eval(`({ + resultVisible: !document.getElementById("view-result").classList.contains("hidden"), + orb: document.getElementById("thinking-orb").dataset.orbState + })`); + assert.equal(resultOrbState.resultVisible, true, 'Result view must be shown once the queue admits the session'); + assert.equal(resultOrbState.orb, 'solving', 'Orb must switch back to "solving" once the query is sent'); + + // Back to prompt for the next tests. + await cdp.eval('document.getElementById("back-btn").click()'); + }); + + // Test 17: Admission Queue - Exit queue button (label timing, clock stubbed) & prompt preserved + await step('Admission Queue: Exit queue label timing & prompt preserved', async () => { + // Stub the wait-start clock so the 1 min / 3 min label thresholds can be exercised + // without a real multi-minute wait (spec §11). + await cdp.eval(` + window.__realDateNow = Date.now.bind(Date); + window.__queueClockOffsetMs = 0; + Date.now = () => window.__realDateNow() + window.__queueClockOffsetMs; + `); + + // A fresh scenario (independent counters, reset-on-scenario-change per spec §10) so this + // flow starts back at "queued" position 3 regardless of the previous test's poll count. + await cdp.eval(` + fetch("/dev/scenario", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenario: "queued_no_estimate" }) + }) + `); + + await cdp.eval('document.getElementById("prompt-input").value = "Exit queue test prompt"'); + await cdp.eval('document.getElementById("submit-btn").click()'); + + await new Promise((r) => setTimeout(r, 300)); + const initialLabel = await cdp.eval('document.getElementById("exit-queue-btn").textContent'); + assert.equal(initialLabel, 'I will try next time', `Expected the under-1-min label, got: ${initialLabel}`); + + // Push the stubbed clock past the 1 minute mark before the next poll refreshes the label. + await cdp.eval('window.__queueClockOffsetMs = 65000;'); + await new Promise((r) => setTimeout(r, 2200)); + const midLabel = await cdp.eval('document.getElementById("exit-queue-btn").textContent'); + assert.equal(midLabel, "Ohhh, it's so long", `Expected the 1-3 min label, got: ${midLabel}`); + + // Push past the 3 minute mark before the next poll. + await cdp.eval('window.__queueClockOffsetMs = 200000;'); + await new Promise((r) => setTimeout(r, 2200)); + const lateLabel = await cdp.eval('document.getElementById("exit-queue-btn").textContent'); + assert.equal(lateLabel, "I'm dying in this queue", `Expected the over-3-min label, got: ${lateLabel}`); + + const ariaLabel = await cdp.eval('document.getElementById("exit-queue-btn").getAttribute("aria-label")'); + assert.equal(ariaLabel, 'Leave the queue', 'aria-label must stay stable while the visible label changes'); + + // Restore the real clock before leaving. + await cdp.eval('Date.now = window.__realDateNow;'); + + // Exit queue: returns to the prompt view with the prompt text preserved, no confirmation. + await cdp.eval('document.getElementById("exit-queue-btn").click()'); + const afterExit = await cdp.eval(`({ + promptVisible: !document.getElementById("view-prompt").classList.contains("hidden"), + loadingHidden: document.getElementById("view-loading").classList.contains("hidden"), + promptValue: document.getElementById("prompt-input").value + })`); + assert.equal(afterExit.promptVisible, true, 'Exiting the queue must return to the prompt view'); + assert.equal(afterExit.loadingHidden, true, 'Loading view must be hidden after exiting the queue'); + assert.equal(afterExit.promptValue, 'Exit queue test prompt', 'Prompt text must be preserved after exiting the queue'); + }); + + // Test 18: Admission Queue - reservation lost right after "ready" rejoins once and succeeds + await step('Admission Queue: reservation lost rejoins once', async () => { + await cdp.eval(` + fetch("/dev/scenario", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenario: "reservation_lost" }) + }) + `); + + await cdp.eval('document.getElementById("submit-btn").click()'); + await new Promise((r) => setTimeout(r, 600)); + + const isResultVisible = await cdp.eval('!document.getElementById("view-result").classList.contains("hidden")'); + assert.equal(isResultVisible, true, 'A reservation lost right after "ready" must transparently rejoin once and still succeed'); + + const promptErrorHidden = await cdp.eval('document.getElementById("prompt-error").classList.contains("hidden")'); + assert.equal(promptErrorHidden, true, 'No busy error should surface to the user after the automatic rejoin'); + + await cdp.eval('document.getElementById("back-btn").click()'); + await cdp.eval(` + fetch("/dev/scenario", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenario: "normal" }) + }) + `); + }); + + // Test 19: Zero Automatic External Requests Network Assertion await step('Zero Automatic External Requests Network Assertion', async () => { // Filter any requests initiated to destinations other than loopback mock server or internal data/blob URLs const externalRequests = cdp.networkRequests.filter((r) => { diff --git a/frontend/tests/queue.test.js b/frontend/tests/queue.test.js new file mode 100644 index 0000000..3c8539d --- /dev/null +++ b/frontend/tests/queue.test.js @@ -0,0 +1,82 @@ +/** + * Unit tests for the pure admission-queue formatting helpers (js/queue.js). + * Covers the exact wording required by docs/QUEUE_SPECIFICATION.md §7.2 and §7.3. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { ordinal, formatEta, formatQueuedStatus, formatExitQueueLabel } from '../js/queue.js'; + +describe('ordinal', () => { + test('handles 1st, 2nd, 3rd, 4th', () => { + assert.equal(ordinal(1), '1st'); + assert.equal(ordinal(2), '2nd'); + assert.equal(ordinal(3), '3rd'); + assert.equal(ordinal(4), '4th'); + }); + + test('handles the 11th-13th exception to the last-digit rule', () => { + assert.equal(ordinal(11), '11th'); + assert.equal(ordinal(12), '12th'); + assert.equal(ordinal(13), '13th'); + }); + + test('handles 21st, 22nd, 23rd and higher tens', () => { + assert.equal(ordinal(21), '21st'); + assert.equal(ordinal(22), '22nd'); + assert.equal(ordinal(23), '23rd'); + assert.equal(ordinal(101), '101st'); + assert.equal(ordinal(111), '111th'); + }); +}); + +describe('formatEta', () => { + test('null or undefined reads as "estimating…"', () => { + assert.equal(formatEta(null), 'estimating…'); + assert.equal(formatEta(undefined), 'estimating…'); + }); + + test('below 60s reads as "under a minute"', () => { + assert.equal(formatEta(0), 'under a minute'); + assert.equal(formatEta(59), 'under a minute'); + }); + + test('60s and above rounds up to "about N min"', () => { + assert.equal(formatEta(60), 'about 1 min'); + assert.equal(formatEta(61), 'about 2 min'); + assert.equal(formatEta(150), 'about 3 min'); + assert.equal(formatEta(120), 'about 2 min'); + }); +}); + +describe('formatQueuedStatus', () => { + test('position 1 reads as "You\'re next"', () => { + assert.equal(formatQueuedStatus({ position: 1, eta_seconds: 40 }), "You're next · under a minute"); + }); + + test('position > 1 uses the ordinal', () => { + assert.equal(formatQueuedStatus({ position: 3, eta_seconds: 220 }), "You're 3rd in line · about 4 min"); + }); + + test('null estimate reads as "estimating…"', () => { + assert.equal(formatQueuedStatus({ position: 3, eta_seconds: null }), "You're 3rd in line · estimating…"); + }); +}); + +describe('formatExitQueueLabel', () => { + test('under 1 minute waited', () => { + assert.equal(formatExitQueueLabel(0), 'I will try next time'); + assert.equal(formatExitQueueLabel(59_000), 'I will try next time'); + }); + + test('1-3 minutes waited (inclusive)', () => { + assert.equal(formatExitQueueLabel(60_000), "Ohhh, it's so long"); + assert.equal(formatExitQueueLabel(90_000), "Ohhh, it's so long"); + assert.equal(formatExitQueueLabel(180_000), "Ohhh, it's so long"); + }); + + test('over 3 minutes waited', () => { + assert.equal(formatExitQueueLabel(180_001), "I'm dying in this queue"); + assert.equal(formatExitQueueLabel(600_000), "I'm dying in this queue"); + }); +});