diff --git a/scripts/ql3-local-alpha-trial-kit-live-contract.cjs b/scripts/ql3-local-alpha-trial-kit-live-contract.cjs index 53443f71..db1ea5a0 100644 --- a/scripts/ql3-local-alpha-trial-kit-live-contract.cjs +++ b/scripts/ql3-local-alpha-trial-kit-live-contract.cjs @@ -474,15 +474,20 @@ async function apiRequest(pathname, token, options = {}) { return Object.freeze({ status: response.status, body }); } -async function consoleSurfaceContract(state) { - let lastError; +async function consoleSurfaceContract(state, adapters = {}) { + const request = adapters.apiRequest ?? apiRequest; + const fetchSurface = adapters.fetch ?? fetch; + const wait = adapters.delay ?? delay; + const readCredential = adapters.credentialToken ?? credentialToken; + let ready = false; + let lastReadyError; for (let attempt = 0; attempt < 20; attempt += 1) { try { - const root = await fetch('http://127.0.0.1:5700/', { + const root = await fetchSurface('http://127.0.0.1:5700/', { redirect: 'manual', signal: AbortSignal.timeout(2_000), }); - const api = await fetch( + const api = await fetchSurface( 'http://127.0.0.1:5700/api/v3/projects/default/tasks', { redirect: 'manual', @@ -496,88 +501,118 @@ async function consoleSurfaceContract(state) { `Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`, ); } - const token = credentialToken(state); - const task = await apiRequest( - '/api/v3/projects/default/tasks/alpha-first-automation', - token, - ); - if ( - task.status !== 200 || - task.body?.task?.revision !== 1 || - !/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '') - ) { - fail( - 'starter Task is not visible through the authenticated Console API', - ); - } - const started = await apiRequest( - '/api/v3/projects/default/tasks/alpha-first-automation/runs', - token, - { - method: 'POST', - body: JSON.stringify({ - schema: 'qinglong/task-start@v1', - mutationId: '019f8680-143d-7000-8000-000000000041', - expectedRevision: task.body.task.revision, - expectedContentDigest: task.body.task.contentDigest, - }), - }, - ); - if ( - started.status !== 202 || - started.body?.status !== 'accepted' || - typeof started.body?.runId !== 'string' || - typeof started.body?.attemptId !== 'string' - ) { - fail('starter Task did not accept one fenced Run'); - } - let terminal; - for (let attempt = 0; attempt < 80; attempt += 1) { - const current = await apiRequest( - `/api/v3/projects/default/runs/${started.body.runId}`, - token, - ); - if (current.status !== 200) fail('starter Run became unreadable'); - if ( - ['succeeded', 'failed', 'cancelled', 'timed_out'].includes( - current.body?.run?.status, - ) - ) { - terminal = current.body.run.status; - break; - } - await delay(250); - } - if (terminal !== 'succeeded') { - fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`); - } - const log = await apiRequest( - `/api/v3/projects/default/runs/${started.body.runId}/attempts/${started.body.attemptId}/log?offset=0&length=32768`, - token, - ); - const logText = - log.status === 200 && log.body?.status === 'available' - ? Buffer.from(log.body.content, 'base64').toString('utf8') - : ''; - if (!logText.includes('qinglong3-alpha-first-automation')) { - fail('starter Run log does not contain the bounded work marker'); - } - return Object.freeze({ - listener: '127.0.0.1:5700', - rootStatus: 200, - unauthenticatedApiStatus: 401, - firstAutomation: Object.freeze({ - taskId: 'alpha-first-automation', - runStatus: terminal, - logMarkerObserved: true, - }), - }); + ready = true; + break; } catch (error) { - lastError = error; - await delay(250); + lastReadyError = error; + await wait(250); } } - throw lastError || new Error('Console listener did not become ready'); + if (!ready) { + throw lastReadyError || new Error('Console listener did not become ready'); + } + + const token = readCredential(state); + const task = await request( + '/api/v3/projects/default/tasks/alpha-first-automation', + token, + ); + if ( + task.status !== 200 || + task.body?.task?.revision !== 1 || + !/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '') + ) { + fail('starter Task is not visible through the authenticated Console API'); + } + const started = await request( + '/api/v3/projects/default/tasks/alpha-first-automation/runs', + token, + { + method: 'POST', + body: JSON.stringify({ + schema: 'qinglong/task-start@v1', + mutationId: '019f8680-143d-7000-8000-000000000041', + expectedRevision: task.body.task.revision, + expectedContentDigest: task.body.task.contentDigest, + }), + }, + ); + const accepted = + (started.status === 202 && started.body?.status === 'accepted') || + (started.status === 200 && started.body?.status === 'existing'); + if ( + !accepted || + typeof started.body?.runId !== 'string' || + typeof started.body?.attemptId !== 'string' + ) { + fail( + `starter Task did not converge one fenced Run: status=${ + started.status + }, code=${ + started.body?.code ?? started.body?.status ?? 'unknown' + }, reason=${started.body?.reason ?? 'none'}`, + ); + } + + let terminal; + for (let attempt = 0; attempt < 80; attempt += 1) { + const current = await request( + `/api/v3/projects/default/runs/${started.body.runId}`, + token, + ); + if (current.status !== 200) { + fail( + `starter Run became unreadable: status=${current.status}, code=${ + current.body?.code ?? 'unknown' + }`, + ); + } + if ( + ['succeeded', 'failed', 'cancelled', 'timed_out'].includes( + current.body?.run?.status, + ) + ) { + terminal = current.body.run.status; + break; + } + await wait(250); + } + if (terminal !== 'succeeded') { + fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`); + } + + let logText; + for (let attempt = 0; attempt < 20; attempt += 1) { + const log = await request( + `/api/v3/projects/default/runs/${started.body.runId}/attempts/${started.body.attemptId}/log?offset=0&length=32768`, + token, + ); + if (log.status === 200 && log.body?.status === 'available') { + logText = Buffer.from(log.body.content, 'base64').toString('utf8'); + break; + } + if (log.status !== 202 || log.body?.status !== 'pending') { + fail( + `starter Run log became unavailable: status=${log.status}, code=${ + log.body?.code ?? log.body?.status ?? 'unknown' + }`, + ); + } + await wait(250); + } + if (!logText?.includes('qinglong3-alpha-first-automation')) { + fail('starter Run log does not contain the bounded work marker'); + } + return Object.freeze({ + listener: '127.0.0.1:5700', + rootStatus: 200, + unauthenticatedApiStatus: 401, + firstAutomation: Object.freeze({ + taskId: 'alpha-first-automation', + runStatus: terminal, + logMarkerObserved: true, + }), + }); } async function runApplication(state) { @@ -777,9 +812,13 @@ async function main() { } } -main().catch((error) => { - process.stderr.write( - `${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; -}); +if (require.main === module) { + main().catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); +} + +module.exports = Object.freeze({ consoleSurfaceContract }); diff --git a/test/back/ql3LocalAlphaTrialKitLiveContract.test.cjs b/test/back/ql3LocalAlphaTrialKitLiveContract.test.cjs new file mode 100644 index 00000000..6a0b8748 --- /dev/null +++ b/test/back/ql3LocalAlphaTrialKitLiveContract.test.cjs @@ -0,0 +1,118 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + consoleSurfaceContract, +} = require('../../scripts/ql3-local-alpha-trial-kit-live-contract.cjs'); + +const DIGEST = 'a'.repeat(64); +const RUN_ID = '019f8680-143d-7000-8000-000000000051'; +const ATTEMPT_ID = '019f8680-143d-7000-8000-000000000052'; + +function surfaceResponse(status) { + return { + status, + body: { cancel: async () => {} }, + }; +} + +function fixture(startStatus) { + let runReads = 0; + let logReads = 0; + const calls = []; + return { + calls, + adapters: { + async fetch(pathname) { + return surfaceResponse(pathname.endsWith('/tasks') ? 401 : 200); + }, + credentialToken() { + return 'private-owner-token'; + }, + async delay() {}, + async apiRequest(pathname, token, options = {}) { + calls.push({ pathname, token, method: options.method ?? 'GET' }); + if (pathname.endsWith('/tasks/alpha-first-automation')) { + return { + status: 200, + body: { task: { revision: 1, contentDigest: DIGEST } }, + }; + } + if (pathname.endsWith('/tasks/alpha-first-automation/runs')) { + return { + status: startStatus === 'accepted' ? 202 : 200, + body: { status: startStatus, runId: RUN_ID, attemptId: ATTEMPT_ID }, + }; + } + if (pathname.endsWith(`/runs/${RUN_ID}`)) { + runReads += 1; + return { + status: 200, + body: { run: { status: runReads === 1 ? 'running' : 'succeeded' } }, + }; + } + if (pathname.includes('/log?')) { + logReads += 1; + return logReads === 1 + ? { status: 202, body: { status: 'pending' } } + : { + status: 200, + body: { + status: 'available', + content: Buffer.from( + 'qinglong3-alpha-first-automation\n', + ).toString('base64'), + }, + }; + } + throw new Error(`unexpected request ${pathname}`); + }, + }, + }; +} + +for (const startStatus of ['accepted', 'existing']) { + test(`proves one ${startStatus} fenced Run after the log becomes available`, async () => { + const { adapters, calls } = fixture(startStatus); + const result = await consoleSurfaceContract({}, adapters); + assert.deepEqual(result, { + listener: '127.0.0.1:5700', + rootStatus: 200, + unauthenticatedApiStatus: 401, + firstAutomation: { + taskId: 'alpha-first-automation', + runStatus: 'succeeded', + logMarkerObserved: true, + }, + }); + assert.equal( + calls.filter(({ pathname }) => pathname.includes('/log?')).length, + 2, + ); + }); +} + +test('preserves a rejected start status, code and reason for diagnosis', async () => { + const { adapters } = fixture('rejected'); + adapters.apiRequest = async (pathname) => { + if (pathname.endsWith('/tasks/alpha-first-automation')) { + return { + status: 200, + body: { task: { revision: 1, contentDigest: DIGEST } }, + }; + } + return { + status: 409, + body: { + code: 'task_start_fence_rejected', + reason: 'definition_changed', + }, + }; + }; + await assert.rejects( + consoleSurfaceContract({}, adapters), + /status=409, code=task_start_fence_rejected, reason=definition_changed/, + ); +});