clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

test(golden): graduate the generated project journey

Dami committed 7b8b8bd
commit
7b8b8bd9506b56cdc7caef288146139524daaea0
parent
a120def02c295889f0595f0e649bb8433ea8bec7
author
Dami <dami.etoile@gmail.com>
9 files changed, +258 -8
Aapps/reference/src/_lofi/device-capabilities_test.ts34+++++++++
Mdeno.json1+
Mdocs/devx-contract.md8++
Mpackage/create.ts15++++-
Mpackage/starter_template.ts4+
Mpackage/testdata/starter.snapshot.json1+
Mtools/golden_path.ts119++++++++++++++++++++++++++++--
Mtools/golden_path_core.ts5+
Atools/golden_path_create.ts79++++++++++++++++++++
A apps/reference/src/_lofi/device-capabilities_test.ts +34 -0
M deno.json +1 -0
M docs/devx-contract.md +8 -0
M package/create.ts +14 -1
M package/starter_template.ts +4 -0
M package/testdata/starter.snapshot.json +1 -0
M tools/golden_path.ts +112 -7
@@ -24,6 +24,7 @@ import { checklistUi } from "../apps/reference/src/ui-contract.ts";
2424 import { assert } from "./assert.ts";2525 2626 export type GoldenPathCommands = {27+ doctor?: string;2728 dev: string;2829 check: string;2930 test: string;
@@ -42,6 +43,14 @@ export type GoldenPathOptions = {
4243 environmentMode?: "isolated-local" | "cloud-from-root";4344 timeoutMs?: number;4445 islandLabels?: [string, string];46+ hmrPath?: string;47+ initialCommands?: CommandRecord[];48+ sourceRevisionRoot?: string;49+ resetArtifactRoot?: boolean;50+ journeyStartedAt?: Date;51+ journeyStartedPerformanceMs?: number;52+ createDurationMs?: number;53+ developerCommandCount?: number;4554 };4655 4756 const defaultCommands: GoldenPathCommands = {
@@ -167,6 +176,40 @@ async function assertRuntimeCardinality(
167176 });168177 }169178 179+async function exerciseHmr(180+ page: Page,181+ projectRoot: string,182+ hmrPath: string,183+ islandLabels: readonly string[],184+ retainedItem: string,185+ timeoutMs: number,186+): Promise<{ samples: number[]; restore: () => Promise<void> }> {187+ const path = resolve(projectRoot, hmrPath);188+ const original = await Deno.readTextFile(path);189+ const samples: number[] = [];190+ for (let cycle = 1; cycle <= 5; cycle++) {191+ const started = performance.now();192+ await Deno.writeTextFile(193+ path,194+ `${original}\n:root {\n --lofi-golden-hmr-cycle: ${cycle};\n}\n`,195+ );196+ await page.waitForFunction(197+ (expected) =>198+ getComputedStyle(document.documentElement).getPropertyValue("--lofi-golden-hmr-cycle")199+ .trim() === expected,200+ String(cycle),201+ { timeout: timeoutMs },202+ );203+ samples.push(Math.round(performance.now() - started));204+ await waitForItem(page, islandLabels, retainedItem);205+ await assertRuntimeCardinality(page, islandLabels);206+ }207+ return {208+ samples,209+ restore: async () => await Deno.writeTextFile(path, original),210+ };211+}212+170213 async function setReferenceConnection(page: Page, connected: boolean): Promise<void> {171214 await page.evaluate(async (shouldConnect) => {172215 const probe = (globalThis as typeof globalThis & {
@@ -357,6 +400,7 @@ async function sourceRevision(
357400 358401 export async function runJourney(options: GoldenPathOptions): Promise<JourneyReport> {359402 const sourceRoot = resolve(options.projectRoot ?? Deno.cwd());403+ const revisionRoot = resolve(options.sourceRevisionRoot ?? sourceRoot);360404 const environmentMode = options.environmentMode ?? "isolated-local";361405 const artifacts = artifactPaths(362406 resolve(
@@ -369,19 +413,21 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
369413 ),370414 ),371415 );372- await Deno.remove(artifacts.root, { recursive: true }).catch((error) => {373- if (!(error instanceof Deno.errors.NotFound)) throw error;374- });416+ if (options.resetArtifactRoot !== false) {417+ await Deno.remove(artifacts.root, { recursive: true }).catch((error) => {418+ if (!(error instanceof Deno.errors.NotFound)) throw error;419+ });420+ }375421 await Deno.mkdir(artifacts.root, { recursive: true });376422 377- const startedAt = new Date();423+ const startedAt = options.journeyStartedAt ?? new Date();378424 const timeoutMs = options.timeoutMs ?? 60_000;379425 const commands = { ...defaultCommands, ...options.commands };380426 const islandLabels = options.islandLabels ?? ["North island", "South island"];381427 const environment = await journeyEnvironment(sourceRoot, environmentMode, artifacts.root);382428 const redactValues = environmentNames.map((name) => environment[name]).filter(Boolean);383- const commit = await sourceRevision(sourceRoot, environment);384- const commandRecords: CommandRecord[] = [];429+ const commit = await sourceRevision(revisionRoot, environment);430+ const commandRecords: CommandRecord[] = [...options.initialCommands ?? []];385431 const assertions: JourneyAssertion[] = [];386432 let projectRoot = sourceRoot;387433 let browser: Browser | null = null;
@@ -392,6 +438,9 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
392438 let browserVersion = "not launched";393439 let devReadyMs: number | undefined;394440 let firstRetainedWriteMs: number | undefined;441+ let createToFirstRetainedWriteMs: number | undefined;442+ let hmrMs: number[] | undefined;443+ let restoreHmr: (() => Promise<void>) | null = null;395444 let failure: unknown;396445 397446 const report: JourneyReport = {
@@ -412,7 +461,8 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
412461 },413462 cacheMode: options.cacheMode ?? "existing",414463 measurements: {415- developerCommandCount: 3,464+ developerCommandCount: options.developerCommandCount ?? (options.source === "create" ? 3 : 1),465+ createMs: options.createDurationMs,416466 },417467 commands: commandRecords,418468 assertions,
@@ -447,6 +497,24 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
447497 detail: "No forbidden runtime, worker, transport, Workbox, or capability plumbing found.",448498 });449499 500+ if (commands.doctor) {501+ const doctor = await runCapturedCommand({502+ cwd: projectRoot,503+ args: taskArgs(commands.doctor),504+ environment,505+ artifactRoot: artifacts.root,506+ name: "doctor",507+ redactValues,508+ });509+ commandRecords.push(doctor);510+ if (doctor.exitCode !== 0) throw commandFailure(doctor);511+ assertions.push({512+ name: "doctor preflight",513+ status: "passed",514+ detail: "Generated diagnostics completed without starting the application.",515+ });516+ }517+450518 try {451519 browser = await chromium.launch({ headless: true });452520 } catch (error) {
@@ -495,6 +563,11 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
495563 await waitForItem(page, islandLabels, retainedBody);496564 await waitForLocalDurability(page);497565 firstRetainedWriteMs = Math.round(performance.now() - devStarted);566+ if (options.journeyStartedPerformanceMs !== undefined) {567+ createToFirstRetainedWriteMs = Math.round(568+ performance.now() - options.journeyStartedPerformanceMs,569+ );570+ }498571 await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });499572 await waitForItem(page, islandLabels, retainedBody);500573 await assertRuntimeCardinality(page, islandLabels);
@@ -505,6 +578,28 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
505578 "The local write survived reload and both islands kept one shared runtime subscription.",506579 });507580 581+ const hmr = await exerciseHmr(582+ page,583+ projectRoot,584+ options.hmrPath ??585+ (options.source === "create"586+ ? "src/styles/global.css"587+ : "apps/reference/src/styles/global.css"),588+ islandLabels,589+ retainedBody,590+ timeoutMs,591+ );592+ hmrMs = hmr.samples;593+ restoreHmr = hmr.restore;594+ assertions.push({595+ name: "HMR state and cardinality",596+ status: "passed",597+ detail:598+ `Five observable style edits retained data with one client, two consumers, and one vendor subscription (${599+ hmrMs.join(", ")600+ }ms).`,601+ });602+508603 await updateItem(page, retainedBody, updatedBody);509604 await waitForItem(page, islandLabels, updatedBody);510605 await completeItem(page, updatedBody);
@@ -542,6 +637,8 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
542637 543638 await dev.stop();544639 dev = null;640+ if (restoreHmr) await restoreHmr();641+ restoreHmr = null;545642 for (546643 const [name, task] of [547644 ["check", commands.check],
@@ -641,6 +738,7 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
641738 }642739 await preview?.stop().catch(() => undefined);643740 await dev?.stop().catch(() => undefined);741+ await restoreHmr?.().catch(() => undefined);644742 await context?.close().catch(() => undefined);645743 await browser?.close().catch(() => undefined);646744 if (options.source === "checkout" && projectRoot !== sourceRoot) {
@@ -655,6 +753,13 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
655753 report.completedAt = new Date().toISOString();656754 report.measurements.devReadyMs = devReadyMs;657755 report.measurements.firstRetainedWriteMs = firstRetainedWriteMs;756+ report.measurements.createToFirstRetainedWriteMs = createToFirstRetainedWriteMs;757+ report.measurements.hmrMs = hmrMs;758+ if (hmrMs?.length) {759+ const sorted = [...hmrMs].sort((left, right) => left - right);760+ report.measurements.hmrMedianMs = sorted[Math.floor(sorted.length / 2)];761+ report.measurements.hmrSlowestMs = sorted.at(-1);762+ }658763 report.runtime.browser = browserVersion;659764 await writeJourneyReport(report);660765 }
M tools/golden_path_core.ts +5 -0
A tools/golden_path_create.ts +79 -0
@@ -0,0 +1,79 @@
1+import { join, resolve } from "node:path";2+import { fileURLToPath } from "node:url";3+import { runJourney } from "./golden_path.ts";4+import {5+ installNodeSentinel,6+ runCapturedCommand,7+ safeChildEnvironment,8+} from "./golden_path_core.ts";9+import { assert } from "./assert.ts";10+11+const sourceRoot = resolve(Deno.cwd());12+const artifactRoot = join(sourceRoot, "test-results/golden-path-create");13+const temporaryRoot = await Deno.makeTempDir({ prefix: "lofi-create-golden-" });14+const projectRoot = join(temporaryRoot, "lofi-app");15+const journeyStartedAt = new Date();16+const journeyStartedPerformanceMs = performance.now();17+18+try {19+ await Deno.remove(artifactRoot, { recursive: true }).catch((error) => {20+ if (!(error instanceof Deno.errors.NotFound)) throw error;21+ });22+ await Deno.mkdir(artifactRoot, { recursive: true });23+ const environment = await installNodeSentinel(safeChildEnvironment(), artifactRoot);24+ environment.LOFI_CREATE_DEVELOPMENT = "1";25+ environment.LOFI_CREATE_PACKAGE_PREFIX = new URL("../package/", import.meta.url).href;26+ const create = await runCapturedCommand({27+ cwd: temporaryRoot,28+ args: [29+ "run",30+ "-A",31+ fileURLToPath(new URL("../package/create.ts", import.meta.url)),32+ "lofi-app",33+ ],34+ environment,35+ artifactRoot,36+ name: "create",37+ });38+ if (create.exitCode !== 0) {39+ throw new Error(`create failed with exit ${create.exitCode}; inspect ${create.stderrPath}`);40+ }41+ const createOutput = await Deno.readTextFile(create.stdoutPath);42+ assert(createOutput.includes("Created lofi-app"), "create output did not name the created path");43+ assert(createOutput.includes(" cd lofi-app"), "create output did not print the exact cd step");44+ assert(45+ createOutput.includes(" deno task dev"),46+ "create output did not print the exact dev step",47+ );48+49+ const report = await runJourney({50+ source: "create",51+ projectRoot,52+ artifactRoot,53+ sourceRevisionRoot: sourceRoot,54+ resetArtifactRoot: false,55+ initialCommands: [create],56+ commands: { doctor: "doctor" },57+ authorPaths: [58+ "src/app.ts",59+ "src/islands",60+ "src/pages",61+ "src/permissions.ts",62+ "src/schema.ts",63+ "src/styles",64+ ],65+ journeyStartedAt,66+ journeyStartedPerformanceMs,67+ createDurationMs: create.durationMs,68+ developerCommandCount: 3,69+ });70+ console.log(`lofi generated golden path: passed (${report.artifacts.report})`);71+} catch (error) {72+ console.error(73+ `lofi generated golden path: failed: ${error instanceof Error ? error.message : error}`,74+ );75+ console.error("rerun: deno task test:golden:create");76+ Deno.exitCode = 1;77+} finally {78+ await Deno.remove(temporaryRoot, { recursive: true }).catch(() => undefined);79+}