Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions tools/consumer-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Hostile decision-logic tests for consumer-smoke (#1216, A10.8 / H6).
*
* The npm availability probe is a release gate: it is wired into the
* post-publish release plan (tools/autoflow/release.ts) and the published
* consumer workflow (.github/workflows/published-consumers.yml). Only a
* CONFIRMED registry 200 whose body confirms the exact version may admit the
* release. Confirmed absence (404) is FAIL; every infra uncertainty —
* timeout, DNS/network exception, 5xx, redirect, malformed or inconsistent
* payload — is UNKNOWN and fails closed (non-zero exit). No path may turn
* infra uncertainty into PASS or SKIP.
*/

import { assertEquals } from '@std/assert';
import { admitsRelease, releaseGateExitCode } from './gate-verdict.ts';
import {
cdnAvailabilityDecision,
classifyRegistryResponse,
npmAvailabilityDecision,
type RegistryFetcher,
} from './consumer-smoke.ts';

const NAME = '@openelement/element';
const VERSION = '0.44.0-alpha.1';

function fetcherReturning(status: number, body: unknown): RegistryFetcher {
return () => Promise.resolve({ status, body: String(body) });
}

function fetcherThrowing(error: Error): RegistryFetcher {
return () => Promise.reject(error);
}

Deno.test('consumer-smoke registry probe: confirmed 200 with matching version is the only PASS', async () => {
const decision = await npmAvailabilityDecision(
NAME,
VERSION,
fetcherReturning(200, JSON.stringify({ name: NAME, version: VERSION })),
);
assertEquals(decision.verdict, 'PASS');
assertEquals(admitsRelease(decision), true);
assertEquals(releaseGateExitCode(decision), 0);
});

Deno.test('consumer-smoke registry probe: confirmed 404 is FAIL (not a silent skip)', async () => {
const decision = await npmAvailabilityDecision(NAME, VERSION, fetcherReturning(404, '{}'));
assertEquals(decision.verdict, 'FAIL');
assertEquals(releaseGateExitCode(decision), 1);
});

Deno.test('consumer-smoke registry probe: 5xx is UNKNOWN and fails closed', async () => {
for (const status of [500, 502, 503]) {
const decision = await npmAvailabilityDecision(
NAME,
VERSION,
fetcherReturning(status, 'upstream error'),
);
assertEquals(decision.verdict, 'UNKNOWN', `status ${status}`);
assertEquals(releaseGateExitCode(decision), 1);
}
});

Deno.test('consumer-smoke registry probe: redirects and other statuses are UNKNOWN', async () => {
for (const status of [301, 403, 418]) {
const decision = await npmAvailabilityDecision(NAME, VERSION, fetcherReturning(status, ''));
assertEquals(decision.verdict, 'UNKNOWN', `status ${status}`);
assertEquals(admitsRelease(decision), false);
}
});

Deno.test('consumer-smoke registry probe: DNS/network exception is UNKNOWN and fails closed', async () => {
const decision = await npmAvailabilityDecision(
NAME,
VERSION,
fetcherThrowing(new TypeError('getaddrinfo ENOTFOUND registry.npmjs.org')),
);
assertEquals(decision.verdict, 'UNKNOWN');
assertEquals(releaseGateExitCode(decision), 1);
});

Deno.test('consumer-smoke registry probe: timeout is UNKNOWN and fails closed', async () => {
const decision = await npmAvailabilityDecision(
NAME,
VERSION,
fetcherThrowing(new DOMException('The operation timed out', 'TimeoutError')),
);
assertEquals(decision.verdict, 'UNKNOWN');
assertEquals(releaseGateExitCode(decision), 1);
});

Deno.test('consumer-smoke registry probe: malformed JSON on 200 is UNKNOWN, never PASS', async () => {
const decision = await npmAvailabilityDecision(
NAME,
VERSION,
fetcherReturning(200, '<!DOCTYPE html><title>proxy error</title>'),
);
assertEquals(decision.verdict, 'UNKNOWN');
assertEquals(releaseGateExitCode(decision), 1);
});

Deno.test('consumer-smoke registry probe: 200 whose payload does not confirm the version is UNKNOWN', async () => {
for (const body of ['{}', JSON.stringify({ version: '0.0.0-other' }), '[]', '"ok"', '42']) {
const decision = await npmAvailabilityDecision(NAME, VERSION, fetcherReturning(200, body));
assertEquals(decision.verdict, 'UNKNOWN', `body ${body}`);
assertEquals(admitsRelease(decision), false);
}
});

Deno.test('consumer-smoke registry classification: pure classifier mirrors the probe verdicts', () => {
assertEquals(
classifyRegistryResponse(NAME, VERSION, 200, JSON.stringify({ version: VERSION })).verdict,
'PASS',
);
assertEquals(classifyRegistryResponse(NAME, VERSION, 404, '{}').verdict, 'FAIL');
assertEquals(classifyRegistryResponse(NAME, VERSION, 500, '').verdict, 'UNKNOWN');
assertEquals(classifyRegistryResponse(NAME, VERSION, 200, 'not json').verdict, 'UNKNOWN');
});

Deno.test('consumer-smoke CDN probe: package published but CDN artifact missing is FAIL', async () => {
const decision = await cdnAvailabilityDecision(VERSION, fetcherReturning(404, 'Not found'));
assertEquals(decision.verdict, 'FAIL');
assertEquals(releaseGateExitCode(decision), 1);
});

Deno.test('consumer-smoke CDN probe: CDN 5xx / network failure is UNKNOWN and fails closed', async () => {
const serverError = await cdnAvailabilityDecision(VERSION, fetcherReturning(503, ''));
assertEquals(serverError.verdict, 'UNKNOWN');
assertEquals(releaseGateExitCode(serverError), 1);

const networkError = await cdnAvailabilityDecision(
VERSION,
fetcherThrowing(new TypeError('network unreachable')),
);
assertEquals(networkError.verdict, 'UNKNOWN');
assertEquals(releaseGateExitCode(networkError), 1);
});

Deno.test('consumer-smoke CDN probe: confirmed 200 with a non-empty export is PASS; empty body is FAIL', async () => {
const ok = await cdnAvailabilityDecision(VERSION, fetcherReturning(200, 'export{/* esm */};'));
assertEquals(ok.verdict, 'PASS');
assertEquals(releaseGateExitCode(ok), 0);

const empty = await cdnAvailabilityDecision(VERSION, fetcherReturning(200, ' \n '));
assertEquals(empty.verdict, 'FAIL');
assertEquals(releaseGateExitCode(empty), 1);
});

Deno.test('consumer-smoke: no hostile registry input maps to PASS or SKIP — every uncertainty exits non-zero', async () => {
const hostile: Array<[string, RegistryFetcher]> = [
['404', fetcherReturning(404, '{}')],
['500', fetcherReturning(500, '')],
['timeout', fetcherThrowing(new DOMException('timed out', 'TimeoutError'))],
['dns', fetcherThrowing(new TypeError('ENOTFOUND'))],
['malformed', fetcherReturning(200, '{')],
['version-mismatch', fetcherReturning(200, JSON.stringify({ version: '9.9.9' }))],
];
for (const [label, fetcher] of hostile) {
const registry = await npmAvailabilityDecision(NAME, VERSION, fetcher);
assertEquals(registry.verdict === 'PASS' || registry.verdict === 'SKIP_ALLOWED', false, label);
assertEquals(releaseGateExitCode(registry), 1, label);
}
});

Deno.test('consumer-smoke: no hostile CDN input maps to PASS or SKIP — every uncertainty exits non-zero', async () => {
// The CDN serves JS text, not JSON: body shape is not evidence. Only status,
// non-emptiness and probe success classify the verdict.
const hostile: Array<[string, RegistryFetcher]> = [
['404-artifact-missing', fetcherReturning(404, 'Not found')],
['500', fetcherReturning(500, '')],
['timeout', fetcherThrowing(new DOMException('timed out', 'TimeoutError'))],
['dns', fetcherThrowing(new TypeError('ENOTFOUND'))],
['empty-200', fetcherReturning(200, ' ')],
];
for (const [label, fetcher] of hostile) {
const cdn = await cdnAvailabilityDecision(VERSION, fetcher);
assertEquals(cdn.verdict === 'PASS' || cdn.verdict === 'SKIP_ALLOWED', false, label);
assertEquals(releaseGateExitCode(cdn), 1, label);
}
});
164 changes: 135 additions & 29 deletions tools/consumer-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@
* verifies @openelement/element can be consumed from npm in Deno and Node.
* Also checks the jsDelivr CDN browser-safe export and Nitro build output.
*
* This script is a RELEASE GATE (#1216, A10.8): it is wired into the
* post-publish release plan (tools/autoflow/release.ts) and the published
* consumer workflow (.github/workflows/published-consumers.yml), so its
* availability probes use the canonical verdict contract
* (tools/gate-verdict.ts) and fail closed. Only a CONFIRMED registry 200
* whose payload confirms the exact version admits the release; a confirmed
* 404 is FAIL; timeout, DNS/network failure, 5xx and malformed responses are
* UNKNOWN — all non-PASS verdicts exit non-zero. There is no skip path:
* infra uncertainty can never green a release.
*
* Usage:
* deno run -A tools/consumer-smoke.ts
* deno run -A tools/consumer-smoke.ts --local
* deno run -A tools/consumer-smoke.ts --version <x.y.z>
* deno run -A tools/consumer-smoke.ts --version <x.y.z> --jsdelivr --nitro
*/

import { formatError } from '@openelement/element';
import { admitsRelease, fail, type GateDecision, pass, unknown } from './gate-verdict.ts';
import { getArg, runWithOutput } from './lib/process.ts';
import { readJson } from './lib/fs.ts';
import { normalizeSlashes } from './lib/path.ts';
Expand Down Expand Up @@ -247,25 +259,125 @@ async function exactVersionStarterSmoke(version: string): Promise<void> {
}
}

async function jsdelivrSmoke(version: string): Promise<void> {
const url = `https://cdn.jsdelivr.net/npm/@openelement/element@${version}/+esm`;
console.log(`\n[jsDelivr CDN browser-safe export] ${url}`);
/** Injectable HTTP probe shape: status code plus raw body text. */
export interface RegistryFetchResponse {
status: number;
body: string;
}

const response = await fetch(url);
const text = await response.text();
export type RegistryFetcher = (url: string) => Promise<RegistryFetchResponse>;

if (response.status !== 200) {
console.error(` failed: status ${response.status}`);
console.error(text.slice(0, 500));
Deno.exit(1);
const PROBE_TIMEOUT_MS = 15_000;

/** Real network probe; the only IO behind the availability decisions. */
async function httpProbe(url: string): Promise<RegistryFetchResponse> {
const response = await fetch(url, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
return { status: response.status, body: await response.text() };
}

/**
* Classify a registry `GET /{name}/{version}` response. PASS requires a 200
* whose JSON payload confirms the exact requested version; a 404 is confirmed
* absence (FAIL); every other status, a malformed body, or a payload that
* does not confirm the version is UNKNOWN — infra uncertainty, fail closed.
*/
export function classifyRegistryResponse(
name: string,
version: string,
status: number,
body: string,
): GateDecision {
if (status === 404) {
return fail(`confirmed absence: ${name}@${version} is not published on npm (registry 404)`);
}
if (status !== 200) {
return unknown(
`registry returned HTTP ${status} for ${name}@${version}; availability cannot be confirmed`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(body) as unknown;
} catch {
return unknown(`malformed registry response for ${name}@${version}; not valid JSON`);
}
if (
typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) ||
(parsed as { version?: unknown }).version !== version
) {
return unknown(
`registry response for ${name}@${version} does not confirm version ${version}`,
);
}
return pass(`${name}@${version} confirmed on npm (registry 200, version payload match)`);
}

if (text.trim().length === 0) {
console.error(' failed: empty response');
Deno.exit(1);
/**
* npm availability verdict for the release gate. Network exceptions
* (DNS failure, timeout, reset) are UNKNOWN, never "absent".
*/
export async function npmAvailabilityDecision(
name: string,
version: string,
fetcher: RegistryFetcher = httpProbe,
): Promise<GateDecision> {
const url = `https://registry.npmjs.org/${name}/${version}`;
let response: RegistryFetchResponse;
try {
response = await fetcher(url);
} catch (error) {
return unknown(`registry probe for ${name}@${version} failed: ${formatError(error)}`);
}
return classifyRegistryResponse(name, version, response.status, response.body);
}

console.log(` ok: ${text.length} bytes`);
/**
* Classify a jsDelivr CDN response for the browser-safe export. PASS requires
* a 200 with a non-empty body; a 404 means the CDN artifact for a published
* package is missing (FAIL); anything else is UNKNOWN.
*/
export function classifyCdnResponse(version: string, status: number, body: string): GateDecision {
if (status === 404) {
return fail(`CDN artifact missing: jsDelivr 404 for @openelement/element@${version}/+esm`);
}
if (status !== 200) {
return unknown(
`jsDelivr returned HTTP ${status} for @openelement/element@${version}; CDN availability cannot be confirmed`,
);
}
if (body.trim().length === 0) {
return fail(
`jsDelivr returned an empty browser-safe export for @openelement/element@${version}`,
);
}
return pass(`jsDelivr browser-safe export confirmed for @openelement/element@${version}`);
}

/** jsDelivr CDN availability verdict for the release gate. */
export async function cdnAvailabilityDecision(
version: string,
fetcher: RegistryFetcher = httpProbe,
): Promise<GateDecision> {
const url = `https://cdn.jsdelivr.net/npm/@openelement/element@${version}/+esm`;
let response: RegistryFetchResponse;
try {
response = await fetcher(url);
} catch (error) {
return unknown(
`jsDelivr probe for @openelement/element@${version} failed: ${formatError(error)}`,
);
}
return classifyCdnResponse(version, response.status, response.body);
}

async function jsdelivrSmoke(version: string): Promise<void> {
console.log(`\n[jsDelivr CDN browser-safe export] @openelement/element@${version}/+esm`);
const decision = await cdnAvailabilityDecision(version);
if (!admitsRelease(decision)) {
console.error(` ${decision.verdict}: ${decision.reason}`);
Deno.exit(1);
}
console.log(` ok: ${decision.reason}`);
}

async function nitroSmoke(): Promise<void> {
Expand All @@ -288,15 +400,6 @@ async function nitroSmoke(): Promise<void> {
}
}

async function npmPackageExists(name: string, version: string): Promise<boolean> {
try {
const response = await fetch(`https://registry.npmjs.org/${name}/${version}`);
return response.status === 200;
} catch {
return false;
}
}

async function main(): Promise<void> {
const { PACKAGE_VERSION } = await import('./project-constants.ts');
const local = getArgFlag('--local');
Expand All @@ -316,14 +419,17 @@ async function main(): Promise<void> {
if (runNitro) console.log(' + Nitro output smoke');

if (!local) {
const exists = await npmPackageExists('@openelement/element', version);
if (!exists) {
console.log(
`\n@openelement/element@${version} is not yet available on npm; skipping npm consumer smoke.`,
);
console.log('Run again after publish, or use --local to test against workspace sources.');
return;
// Release gate, fail closed (#1216): only a confirmed registry 200 with a
// matching version payload admits the smoke. A confirmed 404 is FAIL;
// timeout/DNS/5xx/malformed responses are UNKNOWN. Both exit non-zero —
// infra uncertainty can no longer skip this gate green.
const availability = await npmAvailabilityDecision('@openelement/element', version);
if (!admitsRelease(availability)) {
console.error(`\nnpm availability gate: ${availability.verdict}: ${availability.reason}`);
console.error('Use --local to smoke against workspace sources instead.');
Deno.exit(1);
}
console.log(` npm availability: ${availability.reason}`);
}

await denoNpmSmoke(version, projectRoot, local);
Expand Down
Loading
Loading