Files
webstudio/packages/cli/scripts/release-smoke-report.ts
Oleg Isonen e4983b0d1d fix: Report unsupported Node versions (#6058)
Closes #6057

## Outcome

Generated projects now reject Node.js versions older than 22.12 before
React Router, Remix, or Vite evaluates SDK components. Users see the
package-manager engine error with the current and required versions
instead of the indirect `sanitize-html` / `htmlparser2`
`ERR_REQUIRE_ESM` stack trace.

## Root cause

`sanitize-html@2.17.6` synchronously loads the ESM-only
`htmlparser2@12`. That dependency combination relies on synchronous
`require(ESM)` support, which Node.js enabled by default in 22.12.
Generated projects incorrectly declared the broader `>=22` range,
allowing an unsupported runtime to reach SSR evaluation.

## Implementation

- Set the CLI and all generated base templates to `engines.node:
>=22.12.0`.
- Add npm `devEngines.runtime` with `onFail: error`, which runs before
install, CI, and run commands.
- Enable `engine-strict` in generated npm configuration so pnpm also
rejects an unsupported runtime.
- Regenerate every tracked CLI fixture package manifest.
- Update CLI documentation and the release-smoke manifest contract.
- Remove the earlier sanitizer-bundling workaround from the branch;
sanitizer code and packaging are unchanged from `main`.

## Checklist

- [x] Declare the exact Node.js compatibility floor
- [x] Fail before generated development, build, and start commands
- [x] Cover the generated engine contract with a fail/pass regression
- [x] Regenerate and inspect all affected fixtures
- [x] Review documentation impact and update the CLI guide
- [x] Keep sanitizer behavior and SDK packaging unchanged

## Verification

- Regression demonstrated failing before the template metadata change
and passing afterward
- `pnpm --filter webstudio test` — 946 passed
- `pnpm --filter webstudio typecheck`
- `pnpm --filter webstudio build`
- `pnpm fixtures`
- `pnpm lint`
- `pnpm exec oxfmt --check` on changed source, template, fixture, and
documentation files
- `git diff --check`

Agent evaluations were not run because they require separate approval.
2026-08-09 01:53:31 +02:00

590 lines
17 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { z } from "zod";
import { boundedIdentifierPattern } from "../src/type-utils";
export const releaseSmokePhases = [
"fixture-api",
"package",
"candidate-install",
"baseline-install",
"compact-comparison",
"model-evaluation",
"audit-help",
"preview-start",
"agent-workflow",
"stdio-workflow",
"rendered-audit",
"cached-audit",
"structured-commands",
"cleanup",
] as const;
export const compactSmokeOperations = [
"list-pages",
"list-assets",
"list-instances",
"list-design-tokens",
"list-css-variables",
"list-variables",
"list-resources",
] as const;
export const compactTokenizer = {
implementation: "webstudio-json-lex",
version: "1.0.0",
} as const;
export const defaultCandidateTokenBudget = 6_000;
export type ReleaseSmokePhase = (typeof releaseSmokePhases)[number];
export type CompactSmokeOperation = (typeof compactSmokeOperations)[number];
const byteCount = z.number().int().nonnegative();
const count = z.number().int().nonnegative();
const checkStatus = z.enum(["passed", "failed", "skipped"]);
const safeIdentifier = z
.string()
.min(1)
.max(128)
.regex(boundedIdentifierPattern);
export const countCompactTokens = (source: string) => {
let tokens = 0;
let asciiWordBytes = 0;
let whitespaceBytes = 0;
const flush = () => {
tokens += Math.ceil(asciiWordBytes / 4) + Math.ceil(whitespaceBytes / 4);
asciiWordBytes = 0;
whitespaceBytes = 0;
};
for (const character of source) {
if (/^[A-Za-z0-9_]$/.test(character)) {
if (whitespaceBytes > 0) {
flush();
}
asciiWordBytes += 1;
continue;
}
if (/^\s$/u.test(character)) {
if (asciiWordBytes > 0) {
flush();
}
whitespaceBytes += Buffer.byteLength(character, "utf8");
continue;
}
flush();
tokens += Math.max(1, Math.ceil(Buffer.byteLength(character, "utf8") / 2));
}
flush();
return tokens;
};
export const getModelVerificationItemCount = (
operation: "list-pages" | "list-assets",
structuredContent: unknown
) => {
if (
typeof structuredContent !== "object" ||
structuredContent === null ||
Array.isArray(structuredContent)
) {
throw new Error(`${operation} model verification response is invalid.`);
}
const response = structuredContent as Record<string, unknown>;
const payload = response.data ?? response;
const items = Array.isArray(payload)
? payload
: typeof payload === "object" && payload !== null
? (payload as Record<string, unknown>)[
operation === "list-pages" ? "pages" : "items"
]
: undefined;
if (Array.isArray(items) === false) {
throw new Error(`${operation} model verification payload is invalid.`);
}
return items.length;
};
export const assertPrivacySafeArtifact = (
value: unknown,
canaries: readonly string[] = []
) => {
const source = typeof value === "string" ? value : JSON.stringify(value);
const forbidden = [
...canaries.filter((canary) => canary !== ""),
"authToken=",
"Authorization: Bearer ",
];
if (forbidden.some((secret) => source.includes(secret))) {
throw new Error(
"Release smoke artifact failed privacy redaction scanning."
);
}
if (
/(?:^|[\"'\s])(npm_[A-Za-z0-9]{20,}|gh[opsu]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,})/.test(
source
)
) {
throw new Error("Release smoke artifact contains a token-shaped secret.");
}
};
export const publishedCliManifestContractSchema = z
.object({
name: z.literal("webstudio"),
version: z.string().min(1).max(128),
type: z.literal("module"),
bin: z
.object({
"webstudio-cli": z.literal("./bin.js"),
webstudio: z.literal("./bin.js"),
})
.strict(),
files: z.tuple([
z.literal("lib/*"),
z.literal("templates/*"),
z.literal("bin.js"),
z.literal("!*.{test,stories}.*"),
]),
engines: z.object({ node: z.literal(">=22.12.0") }).strict(),
})
.strict();
export const releaseSmokeMetricsSchema = z
.object({
rssBytes: byteCount.optional(),
heapUsedBytes: byteCount.optional(),
networkTransferBytes: byteCount.optional(),
retainedArtifactBytes: byteCount.optional(),
generatedBuildBytes: byteCount.optional(),
generatedBuildGzipBytes: byteCount.optional(),
})
.strict();
const operationMeasurementFields = {
bytes: byteCount,
tokens: count,
attempts: count,
retries: count,
failures: count,
};
export const compactOperationMeasurementSchema = z
.object({
...operationMeasurementFields,
fallbacks: z
.object({
verbose: count,
fullResource: count,
source: count,
})
.strict(),
})
.strict();
const aggregateMeasurementSchema = z
.object(operationMeasurementFields)
.strict();
const releaseSubjectSchema = z
.object({
source: z.enum(["local", "registry"]),
version: z.string().min(1).max(128),
})
.strict();
export type ReleaseSmokeMetrics = z.infer<typeof releaseSmokeMetricsSchema>;
export type CompactOperationMeasurement = z.infer<
typeof compactOperationMeasurementSchema
>;
export type ReleaseSubject = z.infer<typeof releaseSubjectSchema>;
const comparisonOperationSchema = z
.object({
operation: z.enum(compactSmokeOperations),
compact: compactOperationMeasurementSchema,
verbose: compactOperationMeasurementSchema,
tokenDelta: z.number().int(),
retryDelta: z.number().int(),
failureDelta: z.number().int(),
})
.strict();
const historicalOperationSchema = z
.object({
operation: z.enum(compactSmokeOperations),
candidate: compactOperationMeasurementSchema,
baseline: compactOperationMeasurementSchema,
tokenDelta: z.number().int().nullable(),
})
.strict();
export const releaseSmokeReportSchema = z
.object({
schemaVersion: z.literal(4),
kind: z.literal("deterministic-compact-release-smoke"),
outcome: z.enum(["passed", "failed"]),
totalDurationMs: count,
subjects: z
.object({
candidate: releaseSubjectSchema,
baseline: releaseSubjectSchema,
})
.strict(),
tokenizer: z
.object({
implementation: z.literal(compactTokenizer.implementation),
version: z.literal(compactTokenizer.version),
})
.strict(),
candidateTokenBudget: count.positive(),
sameCandidate: z
.object({
operations: z.array(comparisonOperationSchema),
totals: z
.object({
compact: aggregateMeasurementSchema,
verbose: aggregateMeasurementSchema,
tokenDelta: z.number().int(),
retryDelta: z.number().int(),
failureDelta: z.number().int(),
})
.strict(),
})
.strict(),
historical: z
.object({
comparable: z.boolean(),
operations: z.array(historicalOperationSchema),
totals: z
.object({
candidate: aggregateMeasurementSchema,
baseline: aggregateMeasurementSchema,
tokenDelta: z.number().int().nullable(),
})
.strict(),
})
.strict(),
phases: z.array(
z
.object({
phase: z.enum(releaseSmokePhases),
durationMs: count,
metrics: releaseSmokeMetricsSchema.optional(),
})
.strict()
),
checks: z
.object({
contracts: z
.object({
candidateCompact: checkStatus,
candidateVerbose: checkStatus,
historicalBaselineCompact: checkStatus,
})
.strict(),
candidateTokenBudget: checkStatus,
compactStrictlySmaller: checkStatus,
retryRegression: checkStatus,
failureRegression: checkStatus,
historicalComparable: checkStatus,
canaryRedaction: checkStatus,
registryArtifact: checkStatus,
})
.strict(),
})
.strict();
export type ReleaseSmokeReport = z.infer<typeof releaseSmokeReportSchema>;
export const modelEvaluationTaskId = "minimal-context-discovery-v2" as const;
export const modelEvaluationSubmissionSchema = z
.object({
schemaVersion: z.literal(2),
taskId: z.literal(modelEvaluationTaskId),
provider: safeIdentifier,
model: safeIdentifier,
outcome: z.enum(["completed", "failed"]),
toolCallCount: count,
observations: z.object({ pageCount: count, assetCount: count }).strict(),
})
.strict();
const modelRunSchema = z
.object({
subject: releaseSubjectSchema,
evidence: z
.object({
source: z.literal("configured-agent-command"),
provider: safeIdentifier,
model: safeIdentifier,
commandSha256: z.string().regex(/^[a-f0-9]{64}$/),
durationMs: count,
exitCode: z.number().int(),
toolCallCount: count,
})
.strict(),
observations: z.object({ pageCount: count, assetCount: count }).strict(),
checks: z
.object({ submissionContract: checkStatus, projectOutcome: checkStatus })
.strict(),
})
.strict();
export const modelEvaluationReportSchema = z
.object({
schemaVersion: z.literal(2),
kind: z.literal("model-driven-packaged-cli-evaluation"),
outcome: z.enum(["passed", "failed"]),
subjects: z
.object({
candidate: releaseSubjectSchema,
baseline: releaseSubjectSchema,
})
.strict(),
taskId: z.literal(modelEvaluationTaskId),
runs: z
.object({ candidate: modelRunSchema, baseline: modelRunSchema })
.strict(),
checks: z
.object({ bothOutcomes: checkStatus, canaryRedaction: checkStatus })
.strict(),
})
.strict();
export type ModelEvaluationReport = z.infer<typeof modelEvaluationReportSchema>;
const unknownSubject: ReleaseSubject = {
source: "local",
version: "unresolved",
};
export const writeModelEvaluationReport = async (
path: string,
report: ModelEvaluationReport,
canaries: readonly string[] = []
) => {
const parsed = modelEvaluationReportSchema.parse(report);
assertPrivacySafeArtifact(parsed, canaries);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(parsed, undefined, 2)}\n`, "utf8");
};
const aggregateMeasurements = (
values: readonly CompactOperationMeasurement[]
) =>
values.reduce(
(total, value) => ({
bytes: total.bytes + value.bytes,
tokens: total.tokens + value.tokens,
attempts: total.attempts + value.attempts,
retries: total.retries + value.retries,
failures: total.failures + value.failures,
}),
{ bytes: 0, tokens: 0, attempts: 0, retries: 0, failures: 0 }
);
export const createReleaseSmokeReporter = ({
now = Date.now,
writeLog = (message: string) => process.stderr.write(message),
privacyCanary = "",
candidateTokenBudget = defaultCandidateTokenBudget,
}: {
now?: () => number;
writeLog?: (message: string) => void;
privacyCanary?: string;
candidateTokenBudget?: number;
} = {}) => {
const parsedCandidateTokenBudget = count
.positive()
.parse(candidateTokenBudget);
const startedAt = now();
const phases: ReleaseSmokeReport["phases"] = [];
const sameCandidateOperations: ReleaseSmokeReport["sameCandidate"]["operations"] =
[];
const historicalMeasurements: Array<{
operation: CompactSmokeOperation;
candidate: CompactOperationMeasurement;
baseline: CompactOperationMeasurement;
}> = [];
let subjects: ReleaseSmokeReport["subjects"] = {
candidate: unknownSubject,
baseline: unknownSubject,
};
let contracts: ReleaseSmokeReport["checks"]["contracts"] = {
candidateCompact: "failed",
candidateVerbose: "failed",
historicalBaselineCompact: "failed",
};
let registryArtifact: ReleaseSmokeReport["checks"]["registryArtifact"] =
"skipped";
let canaryExercised = false;
const getComparisons = () => {
const compact = aggregateMeasurements(
sameCandidateOperations.map((operation) => operation.compact)
);
const verbose = aggregateMeasurements(
sameCandidateOperations.map((operation) => operation.verbose)
);
const comparable =
contracts.candidateCompact === "passed" &&
contracts.historicalBaselineCompact === "passed";
const historicalCandidate = aggregateMeasurements(
historicalMeasurements.map((operation) => operation.candidate)
);
const historicalBaseline = aggregateMeasurements(
historicalMeasurements.map((operation) => operation.baseline)
);
return {
sameCandidate: {
operations: sameCandidateOperations,
totals: {
compact,
verbose,
tokenDelta: compact.tokens - verbose.tokens,
retryDelta: compact.retries - verbose.retries,
failureDelta: compact.failures - verbose.failures,
},
},
historical: {
comparable,
operations: historicalMeasurements.map((operation) => ({
...operation,
tokenDelta: comparable
? operation.candidate.tokens - operation.baseline.tokens
: null,
})),
totals: {
candidate: historicalCandidate,
baseline: historicalBaseline,
tokenDelta: comparable
? historicalCandidate.tokens - historicalBaseline.tokens
: null,
},
},
};
};
const createReport = (
outcome: ReleaseSmokeReport["outcome"],
canaryRedaction: ReleaseSmokeReport["checks"]["canaryRedaction"] = "skipped"
): ReleaseSmokeReport => {
const comparisons = getComparisons();
const totals = comparisons.sameCandidate.totals;
const retryRegression = sameCandidateOperations.some(
(operation) => operation.retryDelta > 0
);
const failureRegression = sameCandidateOperations.some(
(operation) => operation.failureDelta > 0
);
return releaseSmokeReportSchema.parse({
schemaVersion: 4,
kind: "deterministic-compact-release-smoke",
outcome,
totalDurationMs: Math.max(0, now() - startedAt),
subjects,
tokenizer: compactTokenizer,
candidateTokenBudget: parsedCandidateTokenBudget,
...comparisons,
phases,
checks: {
contracts,
candidateTokenBudget:
totals.compact.tokens <= parsedCandidateTokenBudget
? "passed"
: "failed",
compactStrictlySmaller:
totals.compact.tokens < totals.verbose.tokens ? "passed" : "failed",
retryRegression: retryRegression ? "failed" : "passed",
failureRegression: failureRegression ? "failed" : "passed",
historicalComparable: comparisons.historical.comparable
? "passed"
: "skipped",
canaryRedaction,
registryArtifact,
},
});
};
return {
complete(
phase: ReleaseSmokePhase,
phaseStartedAt = now(),
metrics?: ReleaseSmokeMetrics
) {
const durationMs = Math.max(0, now() - phaseStartedAt);
phases.push({
phase,
durationMs,
...(metrics === undefined ? {} : { metrics }),
});
writeLog(`[release-smoke] ${phase} (${durationMs}ms)\n`);
},
setSubjects(value: ReleaseSmokeReport["subjects"]) {
subjects = releaseSmokeReportSchema.shape.subjects.parse(value);
},
setContract(
target: keyof ReleaseSmokeReport["checks"]["contracts"],
status: ReleaseSmokeReport["checks"]["contracts"][typeof target]
) {
contracts = { ...contracts, [target]: status };
},
setRegistryArtifact(
status: ReleaseSmokeReport["checks"]["registryArtifact"]
) {
registryArtifact = status;
},
markCanaryExercised() {
canaryExercised = true;
},
recordOperation(
operation: CompactSmokeOperation,
compact: CompactOperationMeasurement,
verbose: CompactOperationMeasurement,
historicalBaseline: CompactOperationMeasurement
) {
const parsedCompact = compactOperationMeasurementSchema.parse(compact);
const parsedVerbose = compactOperationMeasurementSchema.parse(verbose);
const parsedHistoricalBaseline =
compactOperationMeasurementSchema.parse(historicalBaseline);
sameCandidateOperations.push({
operation,
compact: parsedCompact,
verbose: parsedVerbose,
tokenDelta: parsedCompact.tokens - parsedVerbose.tokens,
retryDelta: parsedCompact.retries - parsedVerbose.retries,
failureDelta: parsedCompact.failures - parsedVerbose.failures,
});
historicalMeasurements.push({
operation,
candidate: parsedCompact,
baseline: parsedHistoricalBaseline,
});
},
candidatePassesCompactRollout() {
const report = createReport("failed");
return (
report.checks.contracts.candidateCompact === "passed" &&
report.checks.contracts.candidateVerbose === "passed" &&
report.checks.candidateTokenBudget === "passed" &&
report.checks.compactStrictlySmaller === "passed" &&
report.checks.retryRegression === "passed" &&
report.checks.failureRegression === "passed"
);
},
createReport,
async write(path: string, outcome: ReleaseSmokeReport["outcome"]) {
const report = createReport(
outcome,
canaryExercised ? "passed" : "skipped"
);
assertPrivacySafeArtifact(report, [privacyCanary]);
const source = `${JSON.stringify(report, undefined, 2)}\n`;
releaseSmokeReportSchema.parse(JSON.parse(source));
await mkdir(dirname(path), { recursive: true });
await writeFile(path, source, "utf8");
},
};
};