- commit
- 7b8b8bd9506b56cdc7caef288146139524daaea0
- parent
- a120def02c295889f0595f0e649bb8433ea8bec7
- author
- Dami <dami.etoile@gmail.com>
| A | apps/ | 34 | +++++++++ |
| M | deno.json | 1 | + |
| M | docs/ | 8 | ++ |
| M | package/ | 15 | ++++- |
| M | package/ | 4 | + |
| M | package/ | 1 | + |
| M | tools/ | 119 | ++++++++++++++++++++++++++++-- |
| M | tools/ | 5 | + |
| A | tools/ | 79 | ++++++++++++++++++++ |
@@ -0,0 +1,34 @@
1+import { DurableStorageUnsupportedError } from "./device-capabilities.ts";2+import { assert } from "./test-assert.ts";3+4+const test = (globalThis as unknown as {5+ Deno: { test(name: string, body: () => void | Promise<void>): void };6+}).Deno.test;7+8+test("unsupported durable storage names missing capabilities and the stable-origin action", () => {9+ const error = new DurableStorageUnsupportedError({10+ secureContext: false,11+ opfs: false,12+ sharedWorker: false,13+ webLocks: false,14+ messageChannel: false,15+ durableDriverSupported: false,16+ displayMode: "browser",17+ });18+ assert(error.name === "DurableStorageUnsupportedError", "durability error needs a stable name");19+ for (20+ const capability of [21+ "secureContext",22+ "opfs",23+ "sharedWorker",24+ "webLocks",25+ "messageChannel",26+ ]27+ ) {28+ assert(error.message.includes(capability), `durability error omitted ${capability}`);29+ }30+ assert(31+ error.message.includes("stable HTTPS device URL"),32+ "durability error omitted the stable-origin remediation",33+ );34+});@@ -31,6 +31,7 @@
3131 "test": "deno task test:unit",3232 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",3333 "test:golden:checkout": "deno run -A tools/golden_path_checkout.ts",34+ "test:golden:create": "deno run -A tools/golden_path_create.ts",3435 "dev": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env --allow-write=apps/reference/.env --allow-run tools/run_reference.ts dev",3536 "build": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env,apps/reference/dist --allow-write=apps/reference/.env,apps/reference/dist --allow-run tools/run_reference.ts build",3637 "schema:validate": "deno run -A npm:jazz-tools@2.0.0-alpha.53 validate --schema-dir apps/reference/src",@@ -309,6 +309,14 @@ Layer 1 does not close #11: checkout-mode evidence is necessary, but the generat
309309 cannot pass until Layer 2 implements the public create command. Remaining abstraction leaks are310310 tracked in `docs/m2-abstraction-leaks.md` rather than hidden behind premature package APIs.311311 312+Layer 2 has two distinct package gates. Before publication, the generated-project journey invokes313+the real local `./create` entrypoint and uses an explicitly test-only file-URL package override so314+all generated commands execute from the exact source under review. The default generated output315+still pins `jsr:@nzip/lofi` to the manifest version. This source gate does not claim that the316+registry command exists: after source acceptance and explicit publication authorization, a317+fresh-directory smoke must invoke `deno run -A jsr:@nzip/lofi/create <name>` before #13 and #11 can318+close.319+312320 Layer 1 keeps M1's `lofi-prototype-<appId>` OPFS namespace so existing device rows remain visible to313321 the reviewed notes-to-tasks lens. Jazz discovers that lens and its snapshots beside `schema.ts` in314322 `apps/reference/src/migrations`. `deno task check:migrations` couples the current schema hash,@@ -10,7 +10,20 @@ function usage(): never {
1010 if (Deno.args.length !== 1 || Deno.args[0].startsWith("-")) usage();1111 1212 try {13- const result = await createProject({ cwd: Deno.cwd(), name: Deno.args[0] });13+ const developmentPrefix = Deno.env.get("LOFI_CREATE_DEVELOPMENT") === "1"14+ ? Deno.env.get("LOFI_CREATE_PACKAGE_PREFIX")15+ : undefined;16+ if (17+ developmentPrefix &&18+ (!developmentPrefix.startsWith("file:") || !developmentPrefix.endsWith("/"))19+ ) {20+ throw new Error("internal development package prefix must be a trailing-slash file URL");21+ }22+ const result = await createProject({23+ cwd: Deno.cwd(),24+ name: Deno.args[0],25+ packagePrefix: developmentPrefix,26+ });1427 console.log(`Created ${result.displayPath}`);1528 console.log("");1629 console.log("Next:");@@ -13,6 +13,9 @@ import config from "../apps/reference/src/_lofi/config.ts" with { type: "text" }
1313 import deviceCapabilities from "../apps/reference/src/_lofi/device-capabilities.ts" with {1414 type: "text",1515 };16+import deviceCapabilitiesTest from "../apps/reference/src/_lofi/device-capabilities_test.ts" with {17+ type: "text",18+};1619 import probe from "../apps/reference/src/_lofi/probe.ts" with { type: "text" };1720 import pwa from "../apps/reference/src/_lofi/pwa.ts" with { type: "text" };1821 import pwaTest from "../apps/reference/src/_lofi/pwa_test.ts" with { type: "text" };@@ -69,6 +72,7 @@ export const STARTER_TEMPLATE: Readonly<Record<string, string>> = {
6972 "src/_lofi/checklist-store_test.ts": checklistStoreTest,7073 "src/_lofi/config.ts": config,7174 "src/_lofi/device-capabilities.ts": deviceCapabilities,75+ "src/_lofi/device-capabilities_test.ts": deviceCapabilitiesTest,7276 "src/_lofi/probe.ts": probe,7377 "src/_lofi/pwa.ts": pwa,7478 "src/_lofi/pwa_test.ts": pwaTest,@@ -14,6 +14,7 @@
1414 "src/_lofi/checklist-store_test.ts": "00a7bbf786b90e6bd765e3fe401331233a6fad29a9d473dc4b1f0d5146e4dbc2",1515 "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",1616 "src/_lofi/device-capabilities.ts": "91b03f9bed813d3770061416d40066ed6f21829877293e5395eff8f0f98fc11e",17+ "src/_lofi/device-capabilities_test.ts": "0eece375a0645e915294c7647cba4666e992a8a471cb636cf889bcd323a65ee5",1718 "src/_lofi/probe.ts": "72c8a80561a6b5650d943456e1ce36f08d883fc1008fddee4614a2b75a8f06e9",1819 "src/_lofi/pwa.ts": "185a0668528b49eae2dd0aa905a5a2e491b3787da335d491ffecaf8bfadec430",1920 "src/_lofi/pwa_test.ts": "092d9ff442d82086a03cf45c6ed0256ab0f1ce8db42fe7e636d4b93d6ebae63e",@@ -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 }@@ -44,8 +44,13 @@ export type JourneyReport = {
4444 cacheMode: "existing" | "cold";4545 measurements: {4646 developerCommandCount: number;47+ createMs?: number;48+ createToFirstRetainedWriteMs?: number;4749 devReadyMs?: number;4850 firstRetainedWriteMs?: number;51+ hmrMs?: number[];52+ hmrMedianMs?: number;53+ hmrSlowestMs?: number;4954 };5055 commands: CommandRecord[];5156 assertions: JourneyAssertion[];@@ -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+}