diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index c8e63883..84098499 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -82,3 +82,11 @@ jobs: GITHUB_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + # The page smoke above never touches the build queue. From 2026-08-21 to + # 2026-09-01 every hosted Artifact build failed behind green deploys, + # because workerd rejects a fetch redirect mode that Node accepts. This + # step requests one public Resolution and fails the job unless the build + # reaches `ready` within two minutes. + - name: Smoke an Artifact build + run: node scripts/smoke-artifact-resolution.mjs --timeout-ms 120000 diff --git a/eslint.config.mjs b/eslint.config.mjs index 56c4d21a..e8bbddf2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,4 +7,23 @@ export default antfu({ 'vue/attribute-hyphenation': 'off', 'ts/no-redeclare': 'off', }, -}, ...harlanzw({ base: { type: 'app' } })) +}, ...harlanzw({ base: { type: 'app' } }), { + // Server code runs on workerd, which accepts only the `follow` and `manual` + // redirect modes. Node's undici also accepts `error`, so the unit suite + // cannot catch it: every hosted Artifact build failed from 2026-08-21 to + // 2026-09-01 on one such fetch. The first two selectors repeat antfu's + // defaults, since a rule override replaces the whole option list. + files: ['layers/**/server/**/*.ts', 'server/**/*.ts', 'shared/**/*.ts', 'workers/**/*.ts'], + name: 'skilld/workerd-fetch-redirect', + rules: { + 'no-restricted-syntax': [ + 'error', + 'TSEnumDeclaration[const=true]', + 'TSExportAssignment', + { + selector: 'Property[key.name="redirect"] > Literal[value="error"], Property[key.value="redirect"] > Literal[value="error"]', + message: 'workerd rejects redirect: \'error\'; use fetchNoRedirect()', + }, + ], + }, +}) diff --git a/layers/artifact-delivery/server/utils/fetch-no-redirect.ts b/layers/artifact-delivery/server/utils/fetch-no-redirect.ts new file mode 100644 index 00000000..596b3faa --- /dev/null +++ b/layers/artifact-delivery/server/utils/fetch-no-redirect.ts @@ -0,0 +1,33 @@ +/** + * Fetch without following redirects, on any runtime. + * + * workerd accepts only `redirect: 'follow'` and `redirect: 'manual'`. It + * rejects the `error` redirect mode with a TypeError before the request leaves the + * Worker, while Node's undici accepts it. Every hosted Artifact build failed + * from 2026-08-21 to 2026-09-01 for that reason, and the unit suite on Node + * stayed green. Callers that must not follow a redirect go through this helper + * and decide what an unexpected redirect means for them. + */ + +export type FetchNoRedirectResult + = { _tag: 'ok', response: Response } + | { _tag: 'unexpected-redirect', status: number, location: string | null } + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) + +export async function fetchNoRedirect( + fetcher: typeof globalThis.fetch, + input: string | URL, + init: Omit = {}, +): Promise { + const response = await fetcher(input, { ...init, redirect: 'manual' }) + if (!REDIRECT_STATUSES.has(response.status)) + return { _tag: 'ok', response } + // The redirect body is never read. Cancel it so the connection is released. + await response.body?.cancel() + return { + _tag: 'unexpected-redirect', + status: response.status, + location: response.headers.get('location'), + } +} diff --git a/layers/artifact-delivery/server/utils/github-app.ts b/layers/artifact-delivery/server/utils/github-app.ts index b02465d4..6fdfd17f 100644 --- a/layers/artifact-delivery/server/utils/github-app.ts +++ b/layers/artifact-delivery/server/utils/github-app.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { decryptToken, encryptToken } from '#layers/identity/server/utils/crypto' import { base64ToBytes, bytesToBase64Url } from './encoding' +import { fetchNoRedirect } from './fetch-no-redirect' const GITHUB_API = 'https://api.github.com' const GITHUB_API_VERSION = '2026-03-10' @@ -166,7 +167,7 @@ export async function exchangeGithubAppUserCode( code: string, dependencies: GithubAppUserTokenDependencies, ): Promise { - const response = await dependencies.fetch('https://github.com/login/oauth/access_token', { + const fetched = await fetchNoRedirect(dependencies.fetch, 'https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Accept': 'application/json', @@ -178,9 +179,11 @@ export async function exchangeGithubAppUserCode( client_secret: dependencies.clientSecret, code, }).toString(), - redirect: 'error', signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), }) + if (fetched._tag === 'unexpected-redirect') + throw new Error(`GitHub App authorization redirected ${fetched.status}`) + const response = fetched.response if (response.status === 400 || response.status === 401) return { _tag: 'rejected' } if (!response.ok) @@ -191,16 +194,18 @@ export async function exchangeGithubAppUserCode( const tokens = refreshedUserTokenSchema.safeParse(body) if (!tokens.success) throw new Error('GitHub App authorization returned an invalid response') - const identityResponse = await dependencies.fetch(`${GITHUB_API}/user`, { + const identityFetched = await fetchNoRedirect(dependencies.fetch, `${GITHUB_API}/user`, { headers: { 'Accept': 'application/vnd.github+json', 'Authorization': `Bearer ${tokens.data.access_token}`, 'User-Agent': 'skilld.dev', 'X-GitHub-Api-Version': GITHUB_API_VERSION, }, - redirect: 'error', signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), }) + if (identityFetched._tag === 'unexpected-redirect') + throw new Error(`GitHub App identity request redirected ${identityFetched.status}`) + const identityResponse = identityFetched.response if (identityResponse.status === 401 || identityResponse.status === 403) return { _tag: 'rejected' } if (!identityResponse.ok) @@ -286,7 +291,7 @@ export async function loadAccountGithubAppUserToken( grant_type: 'refresh_token', refresh_token: refreshToken, }) - const response = await dependencies.fetch('https://github.com/login/oauth/access_token', { + const fetched = await fetchNoRedirect(dependencies.fetch, 'https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Accept': 'application/json', @@ -294,9 +299,11 @@ export async function loadAccountGithubAppUserToken( 'User-Agent': 'skilld.dev', }, body: body.toString(), - redirect: 'error', signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), }) + if (fetched._tag === 'unexpected-redirect') + throw new Error(`GitHub App token refresh redirected ${fetched.status}`) + const response = fetched.response if (response.status === 400 || response.status === 401) return await loadRotatedGithubUserToken(db, accountId, row, dependencies) if (!response.ok) @@ -380,7 +387,7 @@ export function createGithubAppClient(config: GithubAppConfig): GithubAppClient schema: z.ZodType, init: { method?: 'GET' | 'POST', body?: unknown } = {}, ): Promise<{ _tag: 'ok', value: T } | { _tag: 'not-found' }> => { - const response = await config.fetch(`${GITHUB_API}${path}`, { + const fetched = await fetchNoRedirect(config.fetch, `${GITHUB_API}${path}`, { method: init.method ?? 'GET', headers: { 'Accept': 'application/vnd.github+json', @@ -390,9 +397,11 @@ export function createGithubAppClient(config: GithubAppConfig): GithubAppClient 'X-GitHub-Api-Version': GITHUB_API_VERSION, }, body: init.body === undefined ? undefined : JSON.stringify(init.body), - redirect: 'error', signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), }) + if (fetched._tag === 'unexpected-redirect') + throw new Error(`GitHub App request redirected ${fetched.status}`) + const response = fetched.response if (response.status === 401 || response.status === 403 || response.status === 404) return { _tag: 'not-found' } if (!response.ok) diff --git a/layers/artifact-delivery/server/utils/github-source.ts b/layers/artifact-delivery/server/utils/github-source.ts index 95c5212f..bd069df0 100644 --- a/layers/artifact-delivery/server/utils/github-source.ts +++ b/layers/artifact-delivery/server/utils/github-source.ts @@ -1,6 +1,7 @@ import type { ProblemCode, ResolvedSource, SourceRequest } from '../schemas/contracts' import { z } from 'zod' import { base64ToBytes, digestHex } from './encoding' +import { fetchNoRedirect } from './fetch-no-redirect' const GITHUB_API = 'https://api.github.com' const GITHUB_API_VERSION = '2026-03-10' @@ -116,11 +117,13 @@ export function createGithubSourceClient(options: GithubClientOptions): PublicGi }) if (options.token) headers.set('Authorization', `Bearer ${options.token}`) - const response = await options.fetch(`${GITHUB_API}${path}`, { + const fetched = await fetchNoRedirect(options.fetch, `${GITHUB_API}${path}`, { headers, - redirect: 'error', signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), }) + if (fetched._tag === 'unexpected-redirect') + throw new Error(`GitHub redirected ${fetched.status} to ${fetched.location ?? 'an unknown location'}`) + const response = fetched.response if (response.status === 404) return { _tag: 'not-found' } if (response.status === 401 || response.status === 403) { diff --git a/layers/artifact-delivery/server/utils/update-plans.ts b/layers/artifact-delivery/server/utils/update-plans.ts index 2cbe6ec9..a180cef1 100644 --- a/layers/artifact-delivery/server/utils/update-plans.ts +++ b/layers/artifact-delivery/server/utils/update-plans.ts @@ -7,6 +7,7 @@ import { comparisonRelationMatches, relationMatchesDirectionalCounts, } from '../schemas/update-plans' +import { fetchNoRedirect } from './fetch-no-redirect' const GITHUB_API = 'https://api.github.com' const GITHUB_API_VERSION = '2026-03-10' @@ -407,18 +408,18 @@ async function requestComparisonPage( if (etag) headers.set('If-None-Match', etag) const url = `${GITHUB_API}/repos/${encodeURIComponent(task.owner)}/${encodeURIComponent(task.repository)}/compare/${task.input.baseSha}...${task.input.headSha}?per_page=${COMMITS_PER_PAGE}&page=${page}` - const responseOutcome = await dependencies.fetch(url, { + const responseOutcome = await fetchNoRedirect(dependencies.fetch, url, { headers, - redirect: 'error', signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), - }).then( - response => ({ _tag: 'response' as const, response }), - () => ({ _tag: 'failed' as const }), - ) + }).catch(() => ({ _tag: 'failed' as const })) if (responseOutcome._tag === 'failed') { dependencies.reportFailure('github-compare') return { _tag: 'provider_failure', status: null } } + if (responseOutcome._tag === 'unexpected-redirect') { + dependencies.reportFailure('github-compare') + return { _tag: 'provider_failure', status: responseOutcome.status } + } const response = responseOutcome.response if (response.status === 304) { diff --git a/scripts/smoke-artifact-resolution.mjs b/scripts/smoke-artifact-resolution.mjs new file mode 100644 index 00000000..76cb0d21 --- /dev/null +++ b/scripts/smoke-artifact-resolution.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Post-deploy smoke for hosted Artifact delivery. + * + * Requests one public Resolution for `skilld-dev/skills` `find-skill` and + * polls it until it is `ready`. The page smoke in `production:deploy` never + * exercised the build queue, so every build failed for eleven days + * (2026-08-21 to 2026-09-01) behind green deploys. This check fails the + * deploy job when a build cannot reach `ready`. + * + * No dependencies. Node 22+. + * + * node scripts/smoke-artifact-resolution.mjs [--base-url URL] [--timeout-ms N] + * + * Env: PRODUCTION_SMOKE_BASE_URL (default https://skilld.dev) + */ +import { randomUUID } from 'node:crypto' +import { pathToFileURL } from 'node:url' + +export const SMOKE_SOURCE = { + provider: 'github', + owner: 'skilld-dev', + repository: 'skills', + selector: { type: 'named-skill', name: 'find-skill' }, +} + +/** + * @typedef {{ _tag: 'ready', resolutionId: string, artifactId: string, polls: number } + * | { _tag: 'failed', reason: 'request_rejected' | 'build_failed' | 'build_blocked' | 'build_revoked' | 'timeout' | 'invalid_response', detail: string, resolutionId?: string }} SmokeOutcome + */ + +/** + * @param {{ + * baseUrl: string + * timeoutMs: number + * fetch?: typeof globalThis.fetch + * wait?: (ms: number) => Promise + * now?: () => number + * log?: (line: string) => void + * }} options + * @returns {Promise} `ready` once the build finishes, else the first failure. + */ +export async function smokeArtifactResolution(options) { + const fetch = options.fetch ?? globalThis.fetch + const wait = options.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) + const now = options.now ?? Date.now + const log = options.log ?? (() => {}) + const headers = { + 'accept': 'application/json', + 'cache-control': 'no-cache', + 'user-agent': 'skilld-artifact-smoke/1', + } + + const created = await fetch(new URL('/api/v1/resolutions', options.baseUrl), { + method: 'POST', + headers: { + ...headers, + 'content-type': 'application/json', + 'idempotency-key': `smoke-${randomUUID()}`, + }, + body: JSON.stringify({ source: SMOKE_SOURCE }), + }) + const createdBody = await readJson(created) + if (created.status !== 200 && created.status !== 202) { + return { + _tag: 'failed', + reason: 'request_rejected', + detail: `POST /api/v1/resolutions returned ${created.status}: ${JSON.stringify(createdBody)}`, + } + } + if (typeof createdBody?.resolutionId !== 'string') { + return { + _tag: 'failed', + reason: 'invalid_response', + detail: `POST /api/v1/resolutions returned no resolutionId: ${JSON.stringify(createdBody)}`, + } + } + const resolutionId = createdBody.resolutionId + log(`resolution ${resolutionId} created (${created.status})`) + + const deadline = now() + options.timeoutMs + let body = createdBody + let polls = 0 + for (;;) { + const terminal = classify(body, resolutionId, polls) + if (terminal) + return terminal + log(`resolution ${resolutionId} ${body.state}${body.stage ? ` (${body.stage})` : ''}`) + if (now() >= deadline) { + return { + _tag: 'failed', + reason: 'timeout', + detail: `resolution ${resolutionId} still ${body.state}${body.stage ? ` (${body.stage})` : ''} after ${options.timeoutMs}ms`, + resolutionId, + } + } + await wait(Math.min(Math.max(Number(body.pollAfterMs) || 1000, 250), 5000)) + polls++ + const polled = await fetch(new URL(`/api/v1/resolutions/${resolutionId}`, options.baseUrl), { headers }) + body = await readJson(polled) + if (polled.status !== 200) { + return { + _tag: 'failed', + reason: 'invalid_response', + detail: `GET /api/v1/resolutions/${resolutionId} returned ${polled.status}: ${JSON.stringify(body)}`, + resolutionId, + } + } + } +} + +/** @returns {SmokeOutcome | null} A terminal outcome, or null while the build is pending. */ +function classify(body, resolutionId, polls) { + switch (body?.state) { + case 'ready': + return { _tag: 'ready', resolutionId, artifactId: String(body.artifact?.artifactId), polls } + case 'failed': + return { + _tag: 'failed', + reason: 'build_failed', + detail: `resolution ${resolutionId} failed with ${body.code}${body.retryable ? ' (retryable)' : ''}`, + resolutionId, + } + case 'blocked': + return { + _tag: 'failed', + reason: 'build_blocked', + detail: `resolution ${resolutionId} was blocked by checks: ${JSON.stringify(body.checkResults)}`, + resolutionId, + } + case 'revoked': + return { + _tag: 'failed', + reason: 'build_revoked', + detail: `resolution ${resolutionId} was revoked: ${body.reasonCode}`, + resolutionId, + } + case 'pending': + return null + default: + return { + _tag: 'failed', + reason: 'invalid_response', + detail: `resolution ${resolutionId} has an unknown state: ${JSON.stringify(body)}`, + resolutionId, + } + } +} + +async function readJson(response) { + const text = await response.text() + try { + return JSON.parse(text) + } + catch { + // A non-JSON body is reported to the caller through the status check. + return { raw: text.slice(0, 500) } + } +} + +function readFlag(name, fallback) { + const index = process.argv.indexOf(name) + return index === -1 ? fallback : process.argv[index + 1] +} + +async function main() { + const baseUrl = readFlag('--base-url', process.env.PRODUCTION_SMOKE_BASE_URL ?? 'https://skilld.dev') + const timeoutMs = Number(readFlag('--timeout-ms', '120000')) + const outcome = await smokeArtifactResolution({ + baseUrl, + timeoutMs, + log: line => console.log(line), + }) + if (outcome._tag === 'ready') { + console.log(`Artifact smoke passed: ${outcome.resolutionId} is ready (artifact ${outcome.artifactId}, ${outcome.polls} polls)`) + return + } + console.error(`Artifact smoke failed (${outcome.reason}): ${outcome.detail}`) + process.exitCode = 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack : String(error)) + process.exitCode = 1 + }) +} diff --git a/test/unit/artifact-delivery.test.ts b/test/unit/artifact-delivery.test.ts index b8039150..973e2f79 100644 --- a/test/unit/artifact-delivery.test.ts +++ b/test/unit/artifact-delivery.test.ts @@ -1,6 +1,7 @@ import type { ResolvedSource, SourceRequest } from '../../layers/artifact-delivery/server/schemas/contracts' import type { ArtifactBuildDependencies } from '../../layers/artifact-delivery/server/utils/build' import type { ArtifactSourceFile, PublicGithubSourceClient } from '../../layers/artifact-delivery/server/utils/github-source' +import { createHash } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import { artifactAttestationSchema, @@ -16,6 +17,7 @@ import { import { processArtifactBuild } from '../../layers/artifact-delivery/server/utils/build' import { checksBlockArtifact } from '../../layers/artifact-delivery/server/utils/checks' import { base64ToBytes, bytesToBase64Url } from '../../layers/artifact-delivery/server/utils/encoding' +import { createPublicGithubSourceClient } from '../../layers/artifact-delivery/server/utils/github-source' import { createPublicArtifactGrant } from '../../layers/artifact-delivery/server/utils/grant' import { ARTIFACT_BUILD_QUEUE_NAME, @@ -123,6 +125,24 @@ describe('public Artifact delivery', () => { expect(dependencies.privateArtifacts).toBeUndefined() }) + it('reaches ready through a runtime that rejects the error redirect mode', async () => { + // Regression for 2026-08-21 to 2026-09-01: every hosted build died at + // `resolving` because workerd, unlike Node, throws on that redirect mode. + const fetch = workerdLikeGithubFetch(validFiles[0]!) + const harness = await createBuildHarness( + validFiles, + false, + resolvedSource.repositoryId, + createPublicGithubSourceClient({ fetch: fetch as typeof globalThis.fetch }), + ) + + const result = await processArtifactBuild(harness.dependencies, harness.resolutionId) + + expect(result).toEqual({ _tag: 'ready', resolutionId: harness.resolutionId }) + expect(fetch).toHaveBeenCalled() + harness.close() + }) + it('blocks failed checks before storage or signing', async () => { const harness = await createBuildHarness([{ ...validFiles[0]!, @@ -559,6 +579,7 @@ async function createBuildHarness( files: ArtifactSourceFile[], privateMode = false, privateResolvedRepositoryId = resolvedSource.repositoryId, + githubOverride?: PublicGithubSourceClient, ) { const sqlite = createSqliteD1(ARTIFACT_MIGRATIONS) if (privateMode) { @@ -605,7 +626,7 @@ async function createBuildHarness( const buildSource = privateMode ? { ...resolvedSource, repositoryId: privateResolvedRepositoryId, visibility: 'private' as const } : resolvedSource - const github: PublicGithubSourceClient = { + const github: PublicGithubSourceClient = githubOverride ?? { resolve: vi.fn(async () => ({ _tag: 'resolved', source: buildSource })), load: vi.fn(async () => ({ _tag: 'loaded', value: { source: buildSource, files } })), } @@ -706,6 +727,61 @@ async function createBuildHarness( } } +/** + * A GitHub API fake with workerd's fetch rule: only `follow` and `manual` + * are accepted redirect modes. Serves the requests a public build makes for + * `sourceRequest` and `resolvedSource` with one Skill file. + */ +function workerdLikeGithubFetch(file: ArtifactSourceFile) { + const skillsTreeSha = '3333333333333333333333333333333333333333' + const demoTreeSha = '4444444444444444444444444444444444444444' + const blobSha = createHash('sha1') + .update(`blob ${file.bytes.byteLength}\0`) + .update(file.bytes) + .digest('hex') + const repo = `/repos/${resolvedSource.owner}/${resolvedSource.repository}` + const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { + status, + headers: { 'content-type': 'application/json' }, + }) + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.redirect === 'error') + throw new TypeError('Invalid redirect value, must be one of "follow" or "manual"') + const path = new URL(String(input)).pathname + new URL(String(input)).search + if (path === repo) { + return json({ + id: resolvedSource.repositoryId, + name: resolvedSource.repository, + owner: { login: resolvedSource.owner }, + private: false, + default_branch: 'main', + }) + } + if (path === `${repo}/commits/${resolvedSource.commitSha}`) + return json({ sha: resolvedSource.commitSha, commit: { tree: { sha: resolvedSource.treeSha } } }) + if (path === `${repo}/git/trees/${resolvedSource.treeSha}`) + return json({ sha: resolvedSource.treeSha, tree: [{ path: 'skills', mode: '040000', type: 'tree', sha: skillsTreeSha }] }) + if (path === `${repo}/git/trees/${skillsTreeSha}`) + return json({ sha: skillsTreeSha, tree: [{ path: 'demo', mode: '040000', type: 'tree', sha: demoTreeSha }] }) + if (path === `${repo}/git/trees/${demoTreeSha}?recursive=1`) { + return json({ + sha: demoTreeSha, + truncated: false, + tree: [{ path: file.path, mode: '100644', type: 'blob', sha: blobSha, size: file.bytes.byteLength }], + }) + } + if (path === `${repo}/git/blobs/${blobSha}`) { + return json({ + sha: blobSha, + size: file.bytes.byteLength, + encoding: 'base64', + content: Buffer.from(file.bytes).toString('base64'), + }) + } + return json({ message: 'Not Found' }, 404) + }) +} + function artifactQueueBatch(resolutionId: string, attempts: number) { const ack = vi.fn() const retry = vi.fn() diff --git a/test/unit/fetch-no-redirect.test.ts b/test/unit/fetch-no-redirect.test.ts new file mode 100644 index 00000000..007c3fdc --- /dev/null +++ b/test/unit/fetch-no-redirect.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { fetchNoRedirect } from '../../layers/artifact-delivery/server/utils/fetch-no-redirect' + +/** + * workerd accepts only `follow` and `manual`. Node's undici also accepts + * `error`, so a unit suite on Node passes while every production fetch throws + * before it leaves the Worker. This fake carries the workerd rule. + */ +function workerdLikeFetch(respond: (url: string) => Response) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.redirect === 'error') + throw new TypeError('Invalid redirect value, must be one of "follow" or "manual"') + return respond(String(input)) + }) +} + +describe('fetchNoRedirect', () => { + it('returns the response for a 200', async () => { + const fetch = workerdLikeFetch(() => new Response('{"ok":true}', { status: 200 })) + + const result = await fetchNoRedirect(fetch, 'https://api.github.com/repos/skilld-dev/skills', { + headers: { Accept: 'application/json' }, + }) + + expect(result._tag).toBe('ok') + if (result._tag === 'ok') + await expect(result.response.json()).resolves.toEqual({ ok: true }) + expect(fetch).toHaveBeenCalledOnce() + expect(fetch.mock.calls[0]?.[1]?.redirect).toBe('manual') + }) + + it('reports a 302 as an unexpected redirect with its location', async () => { + const fetch = workerdLikeFetch(() => new Response(null, { + status: 302, + headers: { location: 'https://github.com/elsewhere' }, + })) + + const result = await fetchNoRedirect(fetch, 'https://api.github.com/repos/skilld-dev/skills') + + expect(result).toEqual({ + _tag: 'unexpected-redirect', + status: 302, + location: 'https://github.com/elsewhere', + }) + }) + + it.each([301, 303, 307, 308])('treats %i as a redirect', async (status) => { + const fetch = workerdLikeFetch(() => new Response(null, { status })) + + const result = await fetchNoRedirect(fetch, 'https://api.github.com/x') + + expect(result).toEqual({ _tag: 'unexpected-redirect', status, location: null }) + }) + + it('passes a 304 through as a response, since it is not a redirect', async () => { + const fetch = workerdLikeFetch(() => new Response(null, { status: 304 })) + + const result = await fetchNoRedirect(fetch, 'https://api.github.com/x', { + headers: { 'If-None-Match': 'W/"etag"' }, + }) + + expect(result).toMatchObject({ _tag: 'ok', response: { status: 304 } }) + }) + + it('passes non-redirect error statuses through so callers keep their own mapping', async () => { + const fetch = workerdLikeFetch(() => new Response('{}', { status: 404 })) + + const result = await fetchNoRedirect(fetch, 'https://api.github.com/x') + + expect(result).toMatchObject({ _tag: 'ok', response: { status: 404 } }) + }) +}) diff --git a/test/unit/smoke-artifact-resolution.test.ts b/test/unit/smoke-artifact-resolution.test.ts new file mode 100644 index 00000000..af90775d --- /dev/null +++ b/test/unit/smoke-artifact-resolution.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { smokeArtifactResolution } from '../../scripts/smoke-artifact-resolution.mjs' + +const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) + +function fakeApi(states: Array>) { + let poll = 0 + const fetch = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { + if (init?.method === 'POST') + return json({ ...states[0], resolutionId: 'res_1' }, 202) + poll++ + return json({ ...states[Math.min(poll, states.length - 1)], resolutionId: 'res_1' }) + }) + return fetch as unknown as typeof globalThis.fetch & { mock: { calls: Array<[URL, RequestInit | undefined]> } } +} + +const base = { baseUrl: 'https://smoke.test', timeoutMs: 120_000, wait: async () => {} } + +describe('smokeArtifactResolution', () => { + it('passes once the resolution becomes ready', async () => { + const fetch = fakeApi([ + { state: 'pending', stage: 'requested', pollAfterMs: 1 }, + { state: 'pending', stage: 'resolving', pollAfterMs: 1 }, + { state: 'ready', artifact: { artifactId: 'art_1' } }, + ]) + + const outcome = await smokeArtifactResolution({ ...base, fetch }) + + expect(outcome).toEqual({ _tag: 'ready', resolutionId: 'res_1', artifactId: 'art_1', polls: 2 }) + expect(new Headers(fetch.mock.calls[0]![1]?.headers).get('idempotency-key')).toMatch(/^smoke-/) + }) + + it('fails with the build error code when the resolution fails', async () => { + const fetch = fakeApi([ + { state: 'pending', stage: 'requested', pollAfterMs: 1 }, + { state: 'failed', code: 'SERVICE_UNAVAILABLE', retryable: true }, + ]) + + const outcome = await smokeArtifactResolution({ ...base, fetch }) + + expect(outcome).toMatchObject({ _tag: 'failed', reason: 'build_failed' }) + expect(outcome._tag === 'failed' && outcome.detail).toContain('SERVICE_UNAVAILABLE') + }) + + it('fails on timeout while the build is still pending', async () => { + let clock = 0 + const fetch = fakeApi([{ state: 'pending', stage: 'resolving', pollAfterMs: 1000 }]) + + const outcome = await smokeArtifactResolution({ + ...base, + fetch, + timeoutMs: 2500, + now: () => clock, + wait: async (ms) => { clock += ms }, + }) + + expect(outcome).toMatchObject({ _tag: 'failed', reason: 'timeout', resolutionId: 'res_1' }) + expect(outcome._tag === 'failed' && outcome.detail).toContain('resolving') + }) + + it('fails when the request itself is rejected', async () => { + const fetch = vi.fn(async () => json({ code: 'INVALID_SOURCE' }, 400)) as unknown as typeof globalThis.fetch + + const outcome = await smokeArtifactResolution({ ...base, fetch }) + + expect(outcome).toMatchObject({ _tag: 'failed', reason: 'request_rejected' }) + }) +})