mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 23:57:17 +09:00
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.
313 lines
9.3 KiB
TypeScript
313 lines
9.3 KiB
TypeScript
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, expect, test, vi } from "vitest";
|
|
import {
|
|
assertPrivacySafeArtifact,
|
|
compactTokenizer,
|
|
countCompactTokens,
|
|
createReleaseSmokeReporter,
|
|
getModelVerificationItemCount,
|
|
modelEvaluationReportSchema,
|
|
modelEvaluationSubmissionSchema,
|
|
publishedCliManifestContractSchema,
|
|
releaseSmokeReportSchema,
|
|
} from "./release-smoke-report";
|
|
|
|
const measurement = (
|
|
bytes: number,
|
|
tokens = bytes,
|
|
retries = 0,
|
|
failures = 0
|
|
) => ({
|
|
bytes,
|
|
tokens,
|
|
attempts: 1 + retries,
|
|
retries,
|
|
failures,
|
|
fallbacks: { verbose: 0, fullResource: 0, source: 0 },
|
|
});
|
|
|
|
describe("comparative release smoke report", () => {
|
|
test("separates the same-candidate gate from incomparable history", () => {
|
|
const times = [100, 125, 180];
|
|
const reporter = createReleaseSmokeReporter({
|
|
now: () => times.shift() ?? 180,
|
|
writeLog: vi.fn(),
|
|
});
|
|
reporter.setSubjects({
|
|
candidate: { source: "local", version: "1.2.3" },
|
|
baseline: { source: "registry", version: "1.2.2" },
|
|
});
|
|
reporter.complete("candidate-install", 110);
|
|
reporter.recordOperation(
|
|
"list-pages",
|
|
measurement(80, 20),
|
|
measurement(160, 40),
|
|
measurement(60, 15)
|
|
);
|
|
reporter.setContract("candidateCompact", "passed");
|
|
reporter.setContract("candidateVerbose", "passed");
|
|
reporter.setContract("historicalBaselineCompact", "failed");
|
|
|
|
expect(reporter.createReport("passed")).toMatchObject({
|
|
schemaVersion: 4,
|
|
kind: "deterministic-compact-release-smoke",
|
|
outcome: "passed",
|
|
totalDurationMs: 80,
|
|
tokenizer: compactTokenizer,
|
|
candidateTokenBudget: 6_000,
|
|
sameCandidate: {
|
|
totals: {
|
|
compact: {
|
|
bytes: 80,
|
|
tokens: 20,
|
|
attempts: 1,
|
|
retries: 0,
|
|
failures: 0,
|
|
},
|
|
verbose: {
|
|
bytes: 160,
|
|
tokens: 40,
|
|
attempts: 1,
|
|
retries: 0,
|
|
failures: 0,
|
|
},
|
|
tokenDelta: -20,
|
|
retryDelta: 0,
|
|
failureDelta: 0,
|
|
},
|
|
},
|
|
historical: {
|
|
comparable: false,
|
|
operations: [{ operation: "list-pages", tokenDelta: null }],
|
|
totals: { tokenDelta: null },
|
|
},
|
|
checks: {
|
|
compactStrictlySmaller: "passed",
|
|
retryRegression: "passed",
|
|
failureRegression: "passed",
|
|
historicalComparable: "skipped",
|
|
},
|
|
});
|
|
expect(reporter.candidatePassesCompactRollout()).toBe(true);
|
|
});
|
|
|
|
test("publishes historical deltas only when both compact contracts pass", () => {
|
|
const reporter = createReleaseSmokeReporter({ writeLog: vi.fn() });
|
|
reporter.recordOperation(
|
|
"list-pages",
|
|
measurement(100, 25),
|
|
measurement(200, 50),
|
|
measurement(120, 30)
|
|
);
|
|
reporter.setContract("candidateCompact", "passed");
|
|
reporter.setContract("candidateVerbose", "passed");
|
|
reporter.setContract("historicalBaselineCompact", "passed");
|
|
|
|
const report = reporter.createReport("passed");
|
|
expect(report.historical).toMatchObject({
|
|
comparable: true,
|
|
operations: [{ tokenDelta: -5 }],
|
|
totals: { tokenDelta: -5 },
|
|
});
|
|
expect(report.checks.historicalComparable).toBe("passed");
|
|
});
|
|
|
|
test("gates token, retry, and failure regressions against candidate verbose", () => {
|
|
const reporter = createReleaseSmokeReporter({
|
|
candidateTokenBudget: 10,
|
|
writeLog: vi.fn(),
|
|
});
|
|
reporter.recordOperation(
|
|
"list-pages",
|
|
measurement(100, 11, 1, 1),
|
|
measurement(100, 11, 0, 0),
|
|
measurement(1, 1)
|
|
);
|
|
reporter.setContract("candidateCompact", "passed");
|
|
reporter.setContract("candidateVerbose", "passed");
|
|
|
|
expect(reporter.createReport("failed").checks).toMatchObject({
|
|
candidateTokenBudget: "failed",
|
|
compactStrictlySmaller: "failed",
|
|
retryRegression: "failed",
|
|
failureRegression: "failed",
|
|
});
|
|
expect(reporter.candidatePassesCompactRollout()).toBe(false);
|
|
});
|
|
|
|
test("uses a deterministic versioned tokenizer", () => {
|
|
expect(countCompactTokens('{"hello":"world"}')).toBe(11);
|
|
expect(countCompactTokens("cafe")).toBe(1);
|
|
expect(countCompactTokens("caf\u00e9")).toBe(2);
|
|
});
|
|
|
|
test("counts current and historical list response shapes", () => {
|
|
expect(
|
|
getModelVerificationItemCount("list-pages", {
|
|
ok: true,
|
|
data: { pages: [{ id: "home" }] },
|
|
})
|
|
).toBe(1);
|
|
expect(
|
|
getModelVerificationItemCount("list-assets", {
|
|
ok: true,
|
|
items: [{ id: "logo" }, { id: "hero" }],
|
|
})
|
|
).toBe(2);
|
|
expect(
|
|
getModelVerificationItemCount("list-assets", {
|
|
ok: true,
|
|
data: [{ id: "logo" }],
|
|
})
|
|
).toBe(1);
|
|
});
|
|
|
|
test("records bounded performance evidence without payload fields", () => {
|
|
const reporter = createReleaseSmokeReporter({
|
|
now: () => 200,
|
|
writeLog: vi.fn(),
|
|
});
|
|
reporter.complete("rendered-audit", 100, {
|
|
rssBytes: 1_000,
|
|
heapUsedBytes: 500,
|
|
networkTransferBytes: 300,
|
|
retainedArtifactBytes: 200,
|
|
generatedBuildBytes: 400,
|
|
generatedBuildGzipBytes: 100,
|
|
});
|
|
expect(reporter.createReport("passed").phases[0]).toMatchObject({
|
|
phase: "rendered-audit",
|
|
durationMs: 100,
|
|
metrics: { rssBytes: 1_000, generatedBuildGzipBytes: 100 },
|
|
});
|
|
});
|
|
|
|
test("rejects raw payloads and private diagnostic fields", () => {
|
|
const report = createReleaseSmokeReporter({
|
|
writeLog: vi.fn(),
|
|
}).createReport("failed");
|
|
expect(releaseSmokeReportSchema.parse(report)).toEqual(report);
|
|
for (const field of ["authToken", "payload", "stdout", "errorMessage"]) {
|
|
expect(
|
|
releaseSmokeReportSchema.safeParse({ ...report, [field]: "private" })
|
|
.success
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("validates the exact published manifest projection", () => {
|
|
const contract = {
|
|
name: "webstudio",
|
|
version: "1.2.3",
|
|
type: "module",
|
|
bin: { "webstudio-cli": "./bin.js", webstudio: "./bin.js" },
|
|
files: ["lib/*", "templates/*", "bin.js", "!*.{test,stories}.*"],
|
|
engines: { node: ">=22.12.0" },
|
|
};
|
|
expect(publishedCliManifestContractSchema.parse(contract)).toEqual(
|
|
contract
|
|
);
|
|
expect(
|
|
publishedCliManifestContractSchema.safeParse({
|
|
...contract,
|
|
bin: { webstudio: "./other.js" },
|
|
}).success
|
|
).toBe(false);
|
|
expect(
|
|
publishedCliManifestContractSchema.safeParse({
|
|
...contract,
|
|
private: true,
|
|
}).success
|
|
).toBe(false);
|
|
});
|
|
|
|
test("requires independent candidate and baseline model evidence", () => {
|
|
const submission = {
|
|
schemaVersion: 2,
|
|
taskId: "minimal-context-discovery-v2",
|
|
provider: "openai",
|
|
model: "gpt-5.1-codex",
|
|
outcome: "completed",
|
|
toolCallCount: 2,
|
|
observations: { pageCount: 2, assetCount: 1 },
|
|
};
|
|
expect(modelEvaluationSubmissionSchema.parse(submission)).toEqual(
|
|
submission
|
|
);
|
|
const run = {
|
|
subject: { source: "local", version: "1.2.3" },
|
|
evidence: {
|
|
source: "configured-agent-command",
|
|
provider: "openai",
|
|
model: "gpt-5.1-codex",
|
|
commandSha256: "a".repeat(64),
|
|
durationMs: 1_000,
|
|
exitCode: 0,
|
|
toolCallCount: 2,
|
|
},
|
|
observations: { pageCount: 2, assetCount: 1 },
|
|
checks: { submissionContract: "passed", projectOutcome: "passed" },
|
|
} as const;
|
|
const report = {
|
|
schemaVersion: 2,
|
|
kind: "model-driven-packaged-cli-evaluation",
|
|
outcome: "passed",
|
|
subjects: {
|
|
candidate: run.subject,
|
|
baseline: { source: "registry", version: "1.2.2" },
|
|
},
|
|
taskId: "minimal-context-discovery-v2",
|
|
runs: {
|
|
candidate: run,
|
|
baseline: {
|
|
...run,
|
|
subject: { source: "registry", version: "1.2.2" },
|
|
},
|
|
},
|
|
checks: { bothOutcomes: "passed", canaryRedaction: "passed" },
|
|
} as const;
|
|
expect(modelEvaluationReportSchema.parse(report)).toEqual(report);
|
|
expect(
|
|
modelEvaluationReportSchema.safeParse({
|
|
...report,
|
|
transcript: "private",
|
|
}).success
|
|
).toBe(false);
|
|
expect(
|
|
modelEvaluationReportSchema.safeParse({
|
|
...report,
|
|
runs: { candidate: run },
|
|
}).success
|
|
).toBe(false);
|
|
});
|
|
|
|
test("scans reports for canaries and token-shaped secrets", async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "release-smoke-report-"));
|
|
const path = join(directory, "report.json");
|
|
const canary = "release-smoke-secret-canary";
|
|
try {
|
|
const reporter = createReleaseSmokeReporter({
|
|
privacyCanary: canary,
|
|
writeLog: vi.fn(),
|
|
});
|
|
reporter.markCanaryExercised();
|
|
await reporter.write(path, "passed");
|
|
const source = await readFile(path, "utf8");
|
|
expect(source).not.toContain(canary);
|
|
expect(releaseSmokeReportSchema.parse(JSON.parse(source))).toMatchObject({
|
|
checks: { canaryRedaction: "passed" },
|
|
});
|
|
expect(() =>
|
|
assertPrivacySafeArtifact({ value: canary }, [canary])
|
|
).toThrow("privacy redaction");
|
|
expect(() =>
|
|
assertPrivacySafeArtifact({ value: `npm_${"a".repeat(24)}` })
|
|
).toThrow("token-shaped");
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|