clone

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

chore: retire prototype spikes, ADRs, and legacy tooling

Dami committed 362a631
Trim prototype-era artifacts that no longer fit a local-first Deno template:
- remove the M-series ADRs (docs/decisions/) and the m2 abstraction-leaks note
- delete one-off golden-path/env/migration/secret check tools and the jazz2
  spike + reference runners under tools/
- drop stale output/ evidence and the retired environment tooling test
- relocate spikes/ to docs/spikes/ and add docs/seed.md
commit
362a631bd93d975d342d0d347785c8915ffbaaed
parent
0559efb261e75b743f2fee4d9b866e979d81fc75
author
Dami <dami.etoile@gmail.com>
39 files changed, +0 -3157
Ddocs/decisions/0001-jazz2-alpha53-baseline.md63--
Ddocs/decisions/0002-graduate-thin-preact-adapter.md33-
Ddocs/decisions/0003-use-static-astro-through-deno.md29-
Ddocs/decisions/0004-reject-alpha53-passkey-backup.md37-
Ddocs/decisions/0005-publish-one-nzip-lofi-package.md38-
Ddocs/decisions/0006-bound-layer3-inspection-and-testing.md80--
Ddocs/decisions/README.md20-
Ddocs/m2-abstraction-leaks.md24-
Rinit.md → docs/seed.md0
Rspikes/astro-deno/evidence/2026-07-15.md → docs/spikes/astro-deno/evidence/2026-07-15.md0
Rspikes/identity/evidence/2026-07-15.md → docs/spikes/identity/evidence/2026-07-15.md0
Rspikes/jazz2-baseline/README.md → docs/spikes/jazz2-baseline/README.md0
Rspikes/jazz2-baseline/evidence/2026-07-15.md → docs/spikes/jazz2-baseline/evidence/2026-07-15.md0
Rspikes/jazz2-baseline/index.html → docs/spikes/jazz2-baseline/index.html0
Rspikes/jazz2-baseline/permissions.ts → docs/spikes/jazz2-baseline/permissions.ts0
Rspikes/jazz2-baseline/schema.ts → docs/spikes/jazz2-baseline/schema.ts0
Rspikes/jazz2-baseline/src/import-meta.d.ts → docs/spikes/jazz2-baseline/src/import-meta.d.ts0
Rspikes/jazz2-baseline/src/main.ts → docs/spikes/jazz2-baseline/src/main.ts0
Rspikes/jazz2-baseline/src/style.css → docs/spikes/jazz2-baseline/src/style.css0
Rspikes/jazz2-baseline/vite.config.ts → docs/spikes/jazz2-baseline/vite.config.ts0
Rspikes/opfs-device/device-checklist.md → docs/spikes/opfs-device/device-checklist.md0
Rspikes/opfs-device/evidence/2026-07-15.md → docs/spikes/opfs-device/evidence/2026-07-15.md0
Rspikes/preact-integration/compat-control/index.html → docs/spikes/preact-integration/compat-control/index.html0
Rspikes/preact-integration/compat-control/src.tsx → docs/spikes/preact-integration/compat-control/src.tsx0
Rspikes/preact-integration/compat-control/vite.config.ts → docs/spikes/preact-integration/compat-control/vite.config.ts0
Rspikes/preact-integration/evidence/2026-07-15.md → docs/spikes/preact-integration/evidence/2026-07-15.md0
Doutput/playwright/jazz2-alpha53-cloud-second-client.pngbin
Doutput/playwright/jazz2-alpha53-local-offline-multitab.pngbin
Dpackage/tooling/environment_test.ts22-
Dtools/check_env.ts21-
Dtools/check_migrations.ts161----
Dtools/check_secret_leaks.ts40-
Dtools/golden_path.ts1220------------------------------
Dtools/golden_path_checkout.ts18-
Dtools/golden_path_core.ts707-----------------
Dtools/golden_path_core_test.ts340--------
Dtools/golden_path_create.ts79--
Dtools/run_jazz2_spike.ts49-
Dtools/run_reference.ts176----
D docs/decisions/0001-jazz2-alpha53-baseline.md +0 -63
@@ -1,63 +0,0 @@
1-# 0001: Graduate the Jazz 2.0.0-alpha.53 data path with lofi-owned edges2-3-Status: accepted for M1 integration\4-Date: 2026-07-15\5-Issue: #7\6-Evidence:7-[`spikes/jazz2-baseline/evidence/2026-07-15.md`](../../spikes/jazz2-baseline/evidence/2026-07-15.md)8-9-## Decision10-11-Use the relational Jazz 2 API from the exact `jazz-tools@2.0.0-alpha.53` npm pin for the M112-prototype:13-14-- declare the schema with `schema.table` and `schema.defineApp`;15-- create one browser `Db` with the persistent driver and a namespaced `BrowserAuthSecretStore`16- identity;17-- render reactive state from `Db.subscribeAll`;18-- treat a write as locally retained only after `wait({ tier: "local" })`;19-- treat a cloud write as confirmed only after `wait({ tier: "global" })`; and20-- keep raw Jazz setup, worker configuration, and transport details behind lofi's runtime module.21-22-Keep `JAZZ_APP_ID` and `JAZZ_SERVER_URL` as lofi's public configuration names. The Deno/Vite edge23-maps the complete validated pair to Jazz's internal `VITE_JAZZ_*` names. `JAZZ_ADMIN_SECRET` and24-`BACKEND_SECRET` remain server-only and unprefixed.25-26-## Contract deltas27-28-- Revise `DX-OBS-01`: configuration proves only that sync is configured. This pin has no public live29- connection-state or pending-operation-count API. lofi may report per-write local/global30- confirmation and explicitly unavailable detail, but not fabricate “connected”.31-- Retain the persistent driver as the default for `DX-DUR-01`; unsupported browser capabilities fail32- boot rather than silently selecting memory.33-- Validate the desktop/browser portion of `DX-LOCAL-01` and the vendor portion of `DX-SYNC-01`.34- Integrated Astro/Preact ownership remains for #5/#6.35-- Confirm the lofi-owned environment names for `DX-ENV-01`; the final Astro build still has to pass36- server-secret scanning.37-38-## Why39-40-The exact package passed local writes, subscription updates, reload retention, offline writes,41-multi-tab propagation, managed-cloud global durability, and a clean second-client download. Its42-relational API matches lofi's intended author model without inventing a second schema language.43-44-The package is alpha and its framework integration assumes a conventional npm layout. Keeping the45-edge lofi-owned lets the product absorb the required Deno/Vite filesystem allowance, first-run env46-pre-seeding, singleton lifecycle, and secret-safe command output without leaking those mechanics47-into product UI.48-49-## Rejected alternatives50-51-- **Use Jazz Classic 0.20 APIs from `init.md`:** those CoValue APIs do not describe this package.52-- **Wrap Jazz in a provider-neutral data abstraction now:** M1 has no second provider and would53- merely create an untested shadow API.54-- **Report connection state from configuration:** a configured URL is not transport evidence.55-- **Fall back to memory automatically:** this violates retained-write and failure-honesty promises.56-- **Track the generated Vite-prefixed `.env`:** it makes vendor implementation names part of the57- public contract and does not solve secret-safe cloud configuration.58-59-## Sources60-61-- [Exact npm package](https://www.npmjs.com/package/jazz-tools/v/2.0.0-alpha.53)62-- [Jazz source snapshot inspected for context](https://github.com/garden-co/jazz/tree/3384730e1c4509524b38c52fc9102fc6753411aa)63-- [Official TypeScript local-first starter at that snapshot](https://github.com/garden-co/jazz/tree/3384730e1c4509524b38c52fc9102fc6753411aa/starters/ts-localfirst)
D docs/decisions/0002-graduate-thin-preact-adapter.md +0 -33
@@ -1,33 +0,0 @@
1-# ADR 0002: Graduate the thin Preact adapter2-3-Status: accepted\4-Date: 2026-07-15\5-Issues: #5, #66-7-## Decision8-9-Use Preact directly over a small lofi-owned external store backed by Jazz's vanilla `Db` API. Keep10-one browser-global database promise, share one vendor query subscription across equivalent island11-consumers, and replace adapter instances during HMR without shutting down the database.12-13-Product components may use Preact, lofi hooks, and application types. They may not import Jazz,14-construct a client/provider, choose a transport, or branch on storage implementation.15-16-## Why17-18-Jazz 2.0.0-alpha.53's React bindings require React 19's `use`. Preact 10.29.7 compat does not export19-that API, so the alias-only control fails its production build. A custom semantic polyfill would be20-a larger and riskier compatibility layer than the adapter it was meant to avoid.21-22-The thin path passed two-root reactivity, retained local writes, explicit and concurrent client23-recreation, teardown, error visibility, deferred-initialization disposal, and five HMR cycles. Its24-reachable JavaScript is 6,983 gzip bytes larger than the single-view vanilla API control; that is an25-indicative floor rather than an equivalent-UI bundle claim.26-27-## Consequences28-29-- Jazz alpha churn is isolated to `apps/reference/src/_lofi` and schema/config files.30-- Component authors get context-free hooks that work across independent Astro islands.31-- The adapter owns query snapshot identity, cleanup, mutation error mapping, and HMR lifecycle.32-- Deferred asynchronous subscription failures remain limited by Jazz's public API and must not be33- represented as observable UI errors until the vendor exposes a channel.
D docs/decisions/0003-use-static-astro-through-deno.md +0 -29
D docs/decisions/0004-reject-alpha53-passkey-backup.md +0 -37
@@ -1,37 +0,0 @@
1-# ADR 0004: Reject Jazz alpha.53 passkey backup2-3-Status: accepted\4-Date: 2026-07-15\5-Issue: #86-7-## Decision8-9-Do not ship or invoke `BrowserPasskeyBackup` from `jazz-tools@2.0.0-alpha.53`. Present the identity10-as a device-local auth key. State explicitly that passkey backup is blocked by security review and11-that clearing site data can make the identity unrecoverable.12-13-Do not expose 24-word phrase export/import in M1. Retain its exact semantics for a later deliberate14-UX: it is a reversible encoding of the full-control auth seed and recovers identity authority, not15-unsynced local data.16-17-## Why18-19-The exact package stores the raw 32-byte Jazz auth secret in the WebAuthn user handle and reads it20-back from `AuthenticatorAssertionResponse.userHandle`. The WebAuthn specification defines that field21-as an account identifier and warns that authenticators may reveal it without prior user22-verification. Ceremony flags and credProtect do not establish a general confidentiality contract for23-the field.24-25-The API also cannot prove that a created credential is synced or manage/revoke its credentials.26-Physical-device success would therefore not establish the security or recovery promise lofi needs.27-28-## Replacement acceptance29-30-A replacement must:31-32-- keep the auth seed out of WebAuthn identifiers and non-confidential metadata;33-- wrap/encrypt the seed using an authenticator-derived or otherwise reviewed secret;34-- bind a stable RP ID and verify the complete ceremony/server record as applicable;35-- distinguish local credential creation from ecosystem backup state;36-- support duplicate prevention, inventory, replacement, and recovery failure UX;37-- retain real iOS and Android browser/installed-app evidence before graduation.
D docs/decisions/0005-publish-one-nzip-lofi-package.md +0 -38
@@ -1,38 +0,0 @@
1-# ADR 0005: Publish one `@nzip/lofi` package2-3-Status: accepted\4-Date: 2026-07-155-6-Owner confirmation: FelineStateMachine explicitly confirmed `@nzip/lofi` as the canonical package7-after #21 and superseded the earlier `@lofi-cat/*` direction. The `@nzip` JSR scope is the owned8-nzip publishing surface and already lists the reserved `@nzip/lofi` package alongside the published9-nzip packages. Publication credentials remain a release-time secret, not repository configuration.10-11-## Decision12-13-Use `@nzip/lofi` as lofi's only JSR package. Public framework boundaries remain explicit subpath14-exports such as `@nzip/lofi/core`, `@nzip/lofi/sync`, `@nzip/lofi/auth`, `@nzip/lofi/ui`, and15-`@nzip/lofi/pwa`. Public tooling graduates under the same package, including the M2 creation command16-at `jsr:@nzip/lofi/create` and testing helpers at `@nzip/lofi/testing`.17-18-Do not publish lockstep `@lofi-cat/*` packages. Internal module boundaries and dependency direction19-remain enforceable even though those modules share one package version.20-21-## Why22-23-The prototype's modules evolve together around a fast-moving pinned Jazz alpha. One JSR package24-provides one version, one publish operation, and direct subpath imports without creating independent25-release promises before the seams have been proven by a second application.26-27-## Consequences28-29-- The M2 public create command is `deno run -A jsr:@nzip/lofi/create <name>`.30-- Consumers import framework APIs through named subpaths, not the package root.31-- Package extraction validates export boundaries, but does not create independently versioned32- artifacts.33-- A future split requires evidence that a module needs an independent compatibility or release34- lifecycle.35-36-## Registry evidence37-38-- [JSR `@nzip` scope](https://jsr.io/@nzip), including the reserved `@nzip/lofi` package
D docs/decisions/0006-bound-layer3-inspection-and-testing.md +0 -80
@@ -1,80 +0,0 @@
1-# 0006: Bound Layer 3 inspection and testing to truthful observable seams2-3-Status: accepted for M2 implementation\4-Date: 2026-07-15\5-Issues: #10, #126-7-## Decision8-9-Layer 3 graduates two developer surfaces:10-11-- a visible development-only inspector backed by a provisional internal snapshot/action adapter;12-- readiness-first Playwright helpers exported from the single package at `@nzip/lofi/testing`.13-14-The inspector is generated runtime machinery, not a public framework facade. `./testing` is the only15-new public package export in this layer. Broader `core`, `ui`, `sync`, `auth`, and `pwa` extraction16-still requires the M4 evidence described by ADR 0005.17-18-The inspector may report only signals lofi can observe:19-20-- device-local identity and the blocked alpha passkey-backup state;21-- requested/open/failed persistent storage plus browser persistence permission;22-- local-only or managed-configured mode;23-- lofi-wrapped per-write local/global durability and mutation failures; and24-- active client, consumer, vendor-subscription, and mutation-listener counts.25-26-The Jazz alpha pin exposes no supported live connection state, vendor queue depth, or multi-tab27-leader/follower role. Those fields remain visibly unavailable. Configuration is never presented as28-connectivity, and checklist writes are not mislabeled as the vendor's complete pending queue.29-30-Inspector transport pause/resume calls Jazz `Db.disconnect()`/`reconnect()` only when managed sync31-is configured and labels the action as a cloud-transport test seam. Real browser offline control is32-owned by `@nzip/lofi/testing` through Playwright `BrowserContext.setOffline()`. Client restart uses33-a full document reload: Jazz alpha.53 does not reliably reopen the same browser-broker namespace34-after an in-document `Db.shutdown()`/`createDb()` cycle, while a document lifecycle restart retains35-the locally durable replica. Replica clear uses `Db.deleteClientStorage()` behind a destructive36-confirmation, then reloads the document; it clears the local OPFS namespace while preserving the37-localStorage identity and warns that unsynced data may be lost.38-39-## Two-client identity rule40-41-The current permissions are creator-only. Two distinct user identities therefore cannot both read42-and edit one row, so claiming distinct-identity convergence would be false. The convergence fixture43-uses two isolated browser contexts and separate OPFS replicas with the same restored identity. The44-first context's Playwright storage state is passed directly to the second context in memory. It is45-never written to reports, traces, snapshots, logs, or screenshots because it contains the bearer46-identity secret.47-48-A truly distinct-user fixture belongs with sharing/invitation and permission-model work, not this49-testing layer.50-51-## Verification boundary52-53-Local acceptance proves readiness predicates, offline restoration in `finally`, restart/reload,54-artifact redaction, inspector actions, and production exclusion. Closing the convergence contract55-also requires a cloud-configured generated-project journey:56-57-1. seed a row and wait for global durability;58-2. boot the second isolated replica with the in-memory restored identity;59-3. take both browser contexts offline;60-4. make orthogonal edits, then reload/recreate while work remains local;61-5. restore network control in `finally` and wait for both visible snapshots to converge; and62-6. remove fixture rows and wait for global durability.63-64-Only the public `JAZZ_APP_ID`/`JAZZ_SERVER_URL` pair reaches the child application. Server secrets65-remain blank, and no configured value is retained in evidence.66-67-Production builds must contain no lofi inspector marker, global bridge, route, or chunk. Jazz's raw68-vendor inspector remains disabled.69-70-## Deferred physical-device evidence71-72-Playwright does not graduate installed-PWA lifecycle, storage eviction, airplane-mode cold launch,73-real passkey custody, or mobile background/foreground recovery. Those remain explicit M3 physical-74-device gates.75-76-## Why77-78-This boundary gives developers useful controls and deterministic tests without inventing vendor79-precision, leaking identity material, weakening creator-only permissions, or prematurely turning the80-M1 runtime into a general framework API.
D docs/decisions/README.md +0 -20
D docs/m2-abstraction-leaks.md +0 -24
@@ -1,24 +0,0 @@
1-# M2 abstraction-leak ledger2-3-Status: active during M2\4-Last reviewed: 2026-07-155-6-This ledger keeps the Layer 1 reference application honest. A listed leak is not a supported lofi7-API; it is a concrete input to the layer that owns removing or productizing it.8-9-| Leak retained in Layer 1 | Why it remains | Owning issue | Graduation evidence |10-| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |11-| Repository-root wrappers still own the checkout journey; generated commands now live in `@nzip/lofi`. | The generated path is implemented without repository-relative paths, but this leak remains listed until exact-head generated golden evidence is retained. | #13, #14 | A generated directory runs its own documented tasks without repository-relative paths. |12-| `schema.ts` and `permissions.ts` import the pinned Jazz schema DSL directly. | M1 rejected inventing a second schema language without a proven ergonomic gain. | Later API extraction after M2 | A second real application demonstrates a smaller stable facade, or the direct DSL is accepted explicitly. |13-| `app.ts` uses a provisional plain configuration object and the Jazz schema value. | Layer 1 needs configuration-as-data but does not yet have two consumers that justify a public factory or exported type. | #13 | The generator emits the approved configuration without raw client, transport, or environment plumbing. |14-| Live socket state, vendor queue depth, and multi-tab role remain unavailable. | Jazz alpha.53 exposes mutation durability but no supported signal for these details; ADR 0006 accepts explicit unavailability instead of fabricated precision. | Jazz baseline review | A later Jazz pin exposes supported signals, or the unavailable boundary remains an intentional product contract. |15-16-## Graduated in Layer 317-18-| Former leak | Graduated surface | Evidence |19-| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |20-| Runtime cardinality and lifecycle evidence used a development-only probe. | The generated dev-only inspector owns redacted diagnostics and safe actions; production builds contain no inspector marker, bridge, route, or chunk. | [ADR 0006](decisions/0006-bound-layer3-inspection-and-testing.md), inspector unit coverage, and generated golden restart/clear/production-exclusion assertions in `tools/golden_path.ts`. |21-| Browser orchestration existed only in the repository checkout journey. | `@nzip/lofi/testing` exports readiness, offline restoration, two-client identity isolation, convergence coordination, and sanitized failure artifacts. | [ADR 0006](decisions/0006-bound-layer3-inspection-and-testing.md), generated subpath-resolution coverage, real Chromium integration, and the cloud two-replica convergence gate. |22-23-Deleting an entry requires retained evidence in the owning issue or a decision record accepting the24-behavior as an intentional public boundary.
R init.md → docs/seed.md +0 -0

No content changes.

R spikes/astro-deno/evidence/2026-07-15.md → docs/spikes/astro-deno/evidence/2026-07-15.md +0 -0

No content changes.

R spikes/identity/evidence/2026-07-15.md → docs/spikes/identity/evidence/2026-07-15.md +0 -0

No content changes.

R spikes/jazz2-baseline/README.md → docs/spikes/jazz2-baseline/README.md +0 -0

No content changes.

R spikes/jazz2-baseline/evidence/2026-07-15.md → docs/spikes/jazz2-baseline/evidence/2026-07-15.md +0 -0

No content changes.

R spikes/jazz2-baseline/index.html → docs/spikes/jazz2-baseline/index.html +0 -0

No content changes.

R spikes/jazz2-baseline/permissions.ts → docs/spikes/jazz2-baseline/permissions.ts +0 -0

No content changes.

R spikes/jazz2-baseline/schema.ts → docs/spikes/jazz2-baseline/schema.ts +0 -0

No content changes.

R spikes/jazz2-baseline/src/import-meta.d.ts → docs/spikes/jazz2-baseline/src/import-meta.d.ts +0 -0

No content changes.

R spikes/jazz2-baseline/src/main.ts → docs/spikes/jazz2-baseline/src/main.ts +0 -0

No content changes.

R spikes/jazz2-baseline/src/style.css → docs/spikes/jazz2-baseline/src/style.css +0 -0

No content changes.

R spikes/jazz2-baseline/vite.config.ts → docs/spikes/jazz2-baseline/vite.config.ts +0 -0

No content changes.

R spikes/opfs-device/device-checklist.md → docs/spikes/opfs-device/device-checklist.md +0 -0

No content changes.

R spikes/opfs-device/evidence/2026-07-15.md → docs/spikes/opfs-device/evidence/2026-07-15.md +0 -0

No content changes.

R spikes/preact-integration/compat-control/index.html → docs/spikes/preact-integration/compat-control/index.html +0 -0

No content changes.

R spikes/preact-integration/compat-control/src.tsx → docs/spikes/preact-integration/compat-control/src.tsx +0 -0

No content changes.

R spikes/preact-integration/compat-control/vite.config.ts → docs/spikes/preact-integration/compat-control/vite.config.ts +0 -0

No content changes.

R spikes/preact-integration/evidence/2026-07-15.md → docs/spikes/preact-integration/evidence/2026-07-15.md +0 -0

No content changes.

D output/playwright/jazz2-alpha53-cloud-second-client.png +0 -0

Binary file.

D output/playwright/jazz2-alpha53-local-offline-multitab.png +0 -0

Binary file.

D package/tooling/environment_test.ts +0 -22
D tools/check_env.ts +0 -21
D tools/check_migrations.ts +0 -161
@@ -1,161 +0,0 @@
1-import { schema as s } from "jazz-tools";2-import { referenceApp } from "../apps/reference/src/app.ts";3-import migration from "../apps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts";4-5-const SCHEMA_DIR = "apps/reference/src";6-const MIGRATIONS_DIR = `${SCHEMA_DIR}/migrations`;7-const SNAPSHOTS_DIR = `${MIGRATIONS_DIR}/snapshots`;8-const JAZZ_TOOLS = "npm:jazz-tools@2.0.0-alpha.53";9-10-function fail(message: string): never {11- throw new Error(`migration contract: ${message}`);12-}13-14-async function jazz(args: string[]): Promise<string> {15- const output = await new Deno.Command(Deno.execPath(), {16- args: ["run", "-A", JAZZ_TOOLS, ...args, "--schema-dir", SCHEMA_DIR],17- stdout: "piped",18- stderr: "piped",19- }).output();20- if (!output.success) {21- const error = new TextDecoder().decode(output.stderr).trim();22- fail(`Jazz CLI failed: ${error || `exit ${output.code}`}`);23- }24- return new TextDecoder().decode(output.stdout).trim();25-}26-27-async function jazzSnapshot(hash: string): Promise<unknown> {28- const isolatedEnvironment: Record<string, string> = {};29- for (30- const name of [31- "JAZZ_APP_ID",32- "JAZZ_SERVER_URL",33- "JAZZ_ADMIN_SECRET",34- "BACKEND_SECRET",35- "VITE_JAZZ_APP_ID",36- "VITE_JAZZ_SERVER_URL",37- "PUBLIC_JAZZ_APP_ID",38- "PUBLIC_JAZZ_SERVER_URL",39- "NEXT_PUBLIC_JAZZ_APP_ID",40- "NEXT_PUBLIC_JAZZ_SERVER_URL",41- "EXPO_PUBLIC_JAZZ_APP_ID",42- "EXPO_PUBLIC_JAZZ_SERVER_URL",43- ]44- ) isolatedEnvironment[name] = "";45- const output = await new Deno.Command(Deno.execPath(), {46- args: [47- "run",48- "-A",49- JAZZ_TOOLS,50- "schema",51- "export",52- "--schema-hash",53- hash,54- "--migrations-dir",55- MIGRATIONS_DIR,56- ],57- env: isolatedEnvironment,58- stdout: "piped",59- stderr: "piped",60- }).output();61- if (!output.success) {62- const error = new TextDecoder().decode(output.stderr).trim();63- fail(`snapshot ${hash} does not hash to its filename: ${error || `exit ${output.code}`}`);64- }65- return JSON.parse(new TextDecoder().decode(output.stdout));66-}67-68-function hashField(source: string, field: "fromHash" | "toHash"): string {69- const match = source.match(new RegExp(`${field}:\\s*["']([0-9a-f]{12})["']`));70- if (!match) fail(`reviewed migration is missing a 12-character ${field}`);71- return match[1];72-}73-74-const hashOutput = await jazz(["schema", "hash"]);75-const currentHash = hashOutput.match(/Current schema hash:\s*([0-9a-f]{12})/)?.[1];76-if (!currentHash) fail(`could not read the current hash from Jazz CLI output: ${hashOutput}`);77-78-const migrationNames: string[] = [];79-for await (const entry of Deno.readDir(MIGRATIONS_DIR)) {80- if (entry.isFile && entry.name.endsWith(".ts")) migrationNames.push(entry.name);81-}82-if (migrationNames.length === 0) fail(`no reviewed migrations found in ${MIGRATIONS_DIR}`);83-84-let currentEdgeFound = false;85-for (const name of migrationNames.sort()) {86- const source = await Deno.readTextFile(`${MIGRATIONS_DIR}/${name}`);87- const fromHash = hashField(source, "fromHash");88- const toHash = hashField(source, "toHash");89- if (!name.endsWith(`-${fromHash}-${toHash}.ts`)) {90- fail(`${name} does not encode its declared ${fromHash} -> ${toHash} edge`);91- }92- for (const hash of [fromHash, toHash]) {93- const snapshotNames = [];94- for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {95- if (entry.isFile && entry.name.endsWith(`-${hash}.json`)) snapshotNames.push(entry.name);96- }97- if (snapshotNames.length !== 1) {98- fail(`${name} requires exactly one ${hash} snapshot; found ${snapshotNames.length}`);99- }100- const snapshot = JSON.parse(101- await Deno.readTextFile(`${SNAPSHOTS_DIR}/${snapshotNames[0]}`),102- );103- if (JSON.stringify(await jazzSnapshot(hash)) !== JSON.stringify(snapshot)) {104- fail(`${snapshotNames[0]} does not round-trip through Jazz's hash loader`);105- }106- }107- if (toHash === currentHash) currentEdgeFound = true;108-}109-if (!currentEdgeFound) {110- fail(`current schema ${currentHash} has no reviewed incoming migration edge`);111-}112-113-const currentSnapshots: string[] = [];114-for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {115- if (entry.isFile && entry.name.endsWith(`-${currentHash}.json`)) {116- currentSnapshots.push(entry.name);117- }118-}119-if (currentSnapshots.length !== 1) {120- fail(`current schema ${currentHash} must have exactly one snapshot`);121-}122-123-const currentSnapshot = JSON.parse(124- await Deno.readTextFile(`${SNAPSHOTS_DIR}/${currentSnapshots[0]}`),125-);126-if (JSON.stringify(referenceApp.schema.wasmSchema) !== JSON.stringify(currentSnapshot)) {127- fail(`current snapshot ${currentSnapshots[0]} does not match schema.ts`);128-}129-130-const previousSnapshotName = [];131-for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {132- if (entry.isFile && entry.name.endsWith("-6c62fec42c35.json")) {133- previousSnapshotName.push(entry.name);134- }135-}136-if (previousSnapshotName.length !== 1) fail("the M1 source snapshot is missing or ambiguous");137-const previousSnapshot = JSON.parse(138- await Deno.readTextFile(`${SNAPSHOTS_DIR}/${previousSnapshotName[0]}`),139-);140-if (JSON.stringify(s.defineApp(migration.from).wasmSchema) !== JSON.stringify(previousSnapshot)) {141- fail("the reviewed lens source does not match the M1 snapshot");142-}143-if (JSON.stringify(s.defineApp(migration.to).wasmSchema) !== JSON.stringify(currentSnapshot)) {144- fail("the reviewed lens target does not match the current snapshot");145-}146-147-const expectedForward = [{148- table: "tasks",149- renamedFrom: "notes",150- operations: [151- { type: "rename", column: "body", value: "text" },152- { type: "introduce", column: "completed", sqlType: "BOOLEAN", value: false },153- ],154-}];155-if (JSON.stringify(migration.forward) !== JSON.stringify(expectedForward)) {156- fail("the reviewed lens must rename notes/body and add completed=false");157-}158-159-console.log(160- `migration contract: ${migrationNames.length} reviewed edge(s), current schema ${currentHash}`,161-);
D tools/check_secret_leaks.ts +0 -40
@@ -1,40 +0,0 @@
1-import { collectBuildArtifactFiles } from "./artifact_files.ts";2-import { serverEnvironmentNames } from "./env_contract.ts";3-import { loadNamedEnvironment } from "./load_env.ts";4-import { findSecretLeaks } from "./secret_scan.ts";5-6-async function sourceFiles(): Promise<string[]> {7- const output = await new Deno.Command("git", {8- args: ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],9- stdout: "piped",10- }).output();11- if (!output.success) throw new Error("Unable to enumerate source files.");12- return new TextDecoder().decode(output.stdout).split("\0").filter(Boolean);13-}14-15-const environment = await loadNamedEnvironment(serverEnvironmentNames);16-const secretValues = serverEnvironmentNames17- .map((name) => ({ name, value: environment[name]?.trim() ?? "" }))18- .filter(({ value }) => value.length > 0);19-const buildArtifacts = await collectBuildArtifactFiles();20-const candidates = [...new Set([...await sourceFiles(), ...buildArtifacts])];21-const files: Array<{ path: string; content: Uint8Array }> = [];22-23-for (const path of candidates) {24- try {25- files.push({ path, content: await Deno.readFile(path) });26- } catch (error) {27- if (error instanceof Deno.errors.NotFound) continue;28- throw error;29- }30-}31-32-const leaks = findSecretLeaks(files, secretValues);33-if (leaks.length > 0) {34- for (const leak of leaks) console.error(`error: ${leak.name} value found in ${leak.path}`);35- Deno.exit(1);36-}37-38-console.log(39- `secret leak check passed (${candidates.length} files including ${buildArtifacts.length} build artifacts, ${secretValues.length} available server secrets)`,40-);
D tools/golden_path.ts +0 -1220
@@ -1,1220 +0,0 @@
1-import { join, resolve } from "node:path";2-import {3- type BrowserTestClient,4- createTwoClientFixture,5- runConcurrentOfflineConvergence,6-} from "@nzip/lofi/testing";7-import { type Browser, type BrowserContext, chromium, type Page } from "playwright";8-import {9- clientEnvironmentNames,10- environmentNames,11- serverEnvironmentNames,12- validateEnvironment,13-} from "./env_contract.ts";14-import {15- artifactPaths,16- type CapturedProcess,17- type CommandRecord,18- initializeFixtureGit,19- installNodeSentinel,20- type JourneyAssertion,21- type JourneyReport,22- type JourneySource,23- materializeCleanProject,24- probeHttpReady,25- reserveLocalPort,26- runCapturedCommand,27- safeChildEnvironment,28- scanAuthorBoundary,29- startReadyProcess,30- writeJourneyReport,31-} from "./golden_path_core.ts";32-import { loadEnvironment } from "./load_env.ts";33-import { checklistUi } from "../apps/reference/src/ui-contract.ts";34-import { redactDiagnosticText } from "../package/testing/safety.ts";35-import { readTraceArchive, sanitizeTraceArchive } from "../package/testing/trace.ts";36-import { assert } from "./assert.ts";37-38-export type GoldenPathCommands = {39- doctor?: string;40- dev: string;41- check: string;42- test: string;43- build: string;44- preview: string;45-};46-47-export type GoldenPathOptions = {48- source: JourneySource;49- /** Repository working tree for checkout mode, or the already-created project for create mode. */50- projectRoot?: string;51- artifactRoot?: string;52- authorPaths?: string[];53- commands?: Partial<GoldenPathCommands>;54- cacheMode?: "existing" | "cold";55- environmentMode?: "isolated-local" | "cloud-from-root";56- timeoutMs?: number;57- islandLabels?: [string, string];58- hmrPath?: string;59- initialCommands?: CommandRecord[];60- sourceRevisionRoot?: string;61- resetArtifactRoot?: boolean;62- journeyStartedAt?: Date;63- journeyStartedPerformanceMs?: number;64- createDurationMs?: number;65- developerCommandCount?: number;66-};67-68-const defaultCommands: GoldenPathCommands = {69- dev: "dev",70- check: "check",71- test: "test",72- build: "build",73- preview: "preview",74-};75-76-const defaultAuthorPaths = [77- "apps/reference/src/app.ts",78- "apps/reference/src/islands",79- "apps/reference/src/pages",80- "apps/reference/src/permissions.ts",81- "apps/reference/src/schema.ts",82- "apps/reference/src/styles",83-];84-85-const retainedBody = "Golden path retained item";86-const updatedBody = "Golden path retained item updated";87-const disposableBody = "Golden path disposable item";88-const offlineDevelopmentBody = "Golden path development offline item";89-const offlineProductionBody = "Golden path production offline item";90-const offlineProductionSyncedBody = "Golden path production offline item synced";91-const replicaClearBody = "Golden path replica-clear disposable item";92-const convergenceTextSeedBody = "Golden path two-client text item";93-const convergenceEditedBody = "Golden path two-client text item edited";94-const convergenceCompletionBody = "Golden path two-client completion item";95-96-function taskArgs(task: string, extra: string[] = []): string[] {97- return ["task", task, ...(extra.length > 0 ? ["--", ...extra] : [])];98-}99-100-function escaped(value: string): string {101- return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");102-}103-104-async function waitForItem(105- page: Page,106- islandLabels: readonly string[],107- body: string,108-): Promise<void> {109- for (const [index, label] of islandLabels.entries()) {110- const island = page.locator("[data-island]").filter({ hasText: label });111- const item = island.getByRole("listitem").filter({ hasText: body });112- if (index === 0) {113- const failure = island.getByRole("status").filter({ hasText: checklistUi.writeFailed });114- await item.or(failure).waitFor({ state: "visible" });115- if (await failure.isVisible()) {116- throw new Error((await failure.textContent()) ?? "Write failed");117- }118- } else {119- await item.waitFor({ state: "visible" });120- }121- }122-}123-124-async function waitForItemAbsent(125- page: Page,126- islandLabels: readonly string[],127- body: string,128-): Promise<void> {129- for (const label of islandLabels) {130- const island = page.locator("[data-island]").filter({ hasText: label });131- await island.getByRole("listitem").filter({ hasText: body }).waitFor({ state: "detached" });132- }133-}134-135-async function addItem(page: Page, island: string, body: string): Promise<void> {136- const section = page.locator("[data-island]").filter({ hasText: island });137- const textbox = section.getByRole("textbox", { name: checklistUi.newItem });138- try {139- await textbox.waitFor({ state: "visible" });140- } catch (error) {141- throw new Error(142- `reference UI is missing accessible textbox "${checklistUi.newItem}" in ${island}; #9 must expose CRUD controls`,143- { cause: error },144- );145- }146- const add = section.getByRole("button", { name: checklistUi.addFrom(island) });147- try {148- await add.waitFor({ state: "visible" });149- } catch (error) {150- throw new Error(151- `reference UI is missing accessible button "${152- checklistUi.addFrom(island)153- }"; #9 must expose CRUD controls`,154- { cause: error },155- );156- }157- await textbox.fill(body);158- await add.click();159-}160-161-async function waitForLocalDurability(page: Page): Promise<void> {162- await page.getByRole("status").filter({163- hasText: new RegExp(164- `(?:${escaped(checklistUi.lastWrite("local"))}|${escaped(checklistUi.lastWrite("global"))})`,165- ),166- }).first().waitFor({ state: "visible" });167-}168-169-async function assertRuntimeCardinality(170- page: Page,171- islandLabels: readonly string[],172-): Promise<void> {173- assert(islandLabels.length === 2, "golden path cardinality requires two mounted islands");174- await page.waitForFunction(async () => {175- const inspector = (globalThis as typeof globalThis & {176- __LOFI_INSPECTOR__?: {177- readSnapshot(): Promise<{178- storage: { driver: string };179- runtime: {180- clients: number;181- consumers: number;182- vendorSubscriptions: number;183- mutationListeners: number;184- };185- }>;186- };187- }).__LOFI_INSPECTOR__;188- const snapshot = await inspector?.readSnapshot();189- return snapshot?.storage.driver === "persistent open" &&190- snapshot.runtime.clients === 1 &&191- snapshot.runtime.consumers === 2 &&192- snapshot.runtime.vendorSubscriptions === 1 &&193- snapshot.runtime.mutationListeners === 1;194- });195-}196-197-async function restartClientThroughInspector(198- page: Page,199- islandLabels: readonly string[],200- retainedItem: string,201- timeoutMs: number,202-): Promise<void> {203- const identity = await armIdentityComparison(page);204- const before = await page.evaluate(() => ({205- timeOrigin: performance.timeOrigin,206- url: location.href,207- }));208- const navigated = page.waitForEvent("domcontentloaded", { timeout: timeoutMs });209- await page.getByRole("button", { name: "Restart client" }).click();210- await navigated;211- const after = await page.evaluate(() => ({212- timeOrigin: performance.timeOrigin,213- url: location.href,214- }));215- assert(after.timeOrigin !== before.timeOrigin, "inspector restart did not replace the document");216- assert(after.url === before.url, "inspector restart changed the application URL");217- assert(218- await identityComparisonMatches(page, identity.token),219- "inspector restart changed the device identity",220- );221- await waitForItem(page, islandLabels, retainedItem);222- for (const label of islandLabels) {223- const island = page.locator("[data-island]").filter({ hasText: label });224- await island.getByRole("status").filter({ hasText: "1 item(s)" }).waitFor({225- state: "visible",226- timeout: timeoutMs,227- });228- }229- await assertRuntimeCardinality(page, islandLabels);230-}231-232-async function armIdentityComparison(page: Page): Promise<{ token: string; present: boolean }> {233- const token = crypto.randomUUID();234- const present = await page.evaluate(async (comparisonToken) => {235- const identity = JSON.stringify(236- Object.entries(localStorage).sort(([left], [right]) => left.localeCompare(right)),237- );238- const digest = new Uint8Array(239- await crypto.subtle.digest("SHA-256", new TextEncoder().encode(identity)),240- );241- const hex = Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");242- (globalThis as typeof globalThis & { name: string }).name =243- `lofi-golden:${comparisonToken}:${hex}`;244- return identity !== "[]";245- }, token);246- return { token, present };247-}248-249-async function identityComparisonMatches(page: Page, token: string): Promise<boolean> {250- return await page.evaluate(async (comparisonToken) => {251- const browserGlobal = globalThis as typeof globalThis & { name: string };252- const expected = browserGlobal.name;253- browserGlobal.name = "";254- const identity = JSON.stringify(255- Object.entries(localStorage).sort(([left], [right]) => left.localeCompare(right)),256- );257- const digest = new Uint8Array(258- await crypto.subtle.digest("SHA-256", new TextEncoder().encode(identity)),259- );260- const hex = Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");261- return expected === `lofi-golden:${comparisonToken}:${hex}`;262- }, token);263-}264-265-async function exerciseHmr(266- page: Page,267- projectRoot: string,268- hmrPath: string,269- islandLabels: readonly string[],270- retainedItem: string,271- timeoutMs: number,272-): Promise<{ samples: number[]; restore: () => Promise<void> }> {273- const path = resolve(projectRoot, hmrPath);274- const original = await Deno.readTextFile(path);275- const samples: number[] = [];276- for (let cycle = 1; cycle <= 5; cycle++) {277- const started = performance.now();278- await Deno.writeTextFile(279- path,280- `${original}\n:root {\n --lofi-golden-hmr-cycle: ${cycle};\n}\n`,281- );282- await page.waitForFunction(283- (expected) =>284- getComputedStyle(document.documentElement).getPropertyValue("--lofi-golden-hmr-cycle")285- .trim() === expected,286- String(cycle),287- { timeout: timeoutMs },288- );289- samples.push(Math.round(performance.now() - started));290- await waitForItem(page, islandLabels, retainedItem);291- await assertRuntimeCardinality(page, islandLabels);292- }293- return {294- samples,295- restore: async () => await Deno.writeTextFile(path, original),296- };297-}298-299-async function setReferenceConnection(page: Page, connected: boolean): Promise<void> {300- await page.evaluate(async (shouldConnect) => {301- const inspector = (globalThis as typeof globalThis & {302- __LOFI_INSPECTOR__?: { setTransportPaused(paused: boolean): Promise<void> };303- }).__LOFI_INSPECTOR__;304- if (!inspector) throw new Error("development inspector is unavailable");305- await inspector.setTransportPaused(!shouldConnect);306- }, connected);307-}308-309-async function exerciseReplicaClear(310- browser: Browser,311- url: string,312- islandLabels: readonly string[],313- timeoutMs: number,314-): Promise<void> {315- const context = await browser.newContext();316- try {317- const page = await context.newPage();318- await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });319- await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({320- state: "visible",321- });322- await assertRuntimeCardinality(page, islandLabels);323- await addItem(page, islandLabels[0], replicaClearBody);324- await waitForItem(page, islandLabels, replicaClearBody);325- await waitForLocalDurability(page);326- const identity = await armIdentityComparison(page);327- assert(identity.present, "replica-clear fixture did not create an identity state");328- const navigated = page.waitForEvent("domcontentloaded", { timeout: timeoutMs });329- page.once("dialog", (dialog) => dialog.accept());330- await page.getByRole("button", { name: "Clear local replica" }).click();331- await navigated;332- await waitForItemAbsent(page, islandLabels, replicaClearBody);333- assert(334- await identityComparisonMatches(page, identity.token),335- "replica clear changed the device-local identity",336- );337- } finally {338- await context.close();339- }340-}341-342-async function inspectorMarkerFiles(root: string): Promise<string[]> {343- const matches: string[] = [];344- async function visit(path: string): Promise<void> {345- for await (const entry of Deno.readDir(path)) {346- const child = join(path, entry.name);347- if (entry.isDirectory) await visit(child);348- else if (entry.isFile) {349- const content = await Deno.readTextFile(child).catch(() => "");350- if (content.includes("lofi-development-inspector")) matches.push(child);351- }352- }353- }354- await visit(root);355- return matches.sort();356-}357-358-async function observeReconnectSettlement(359- page: Page,360- timeoutMs: number,361- required: boolean,362-): Promise<JourneyAssertion> {363- if (!required) {364- return {365- name: "development reconnect settlement",366- status: "blocked",367- detail:368- "Local-only mode has no global durability endpoint; offline retention passed, but this run makes no transport convergence claim.",369- };370- }371- try {372- const global = page.getByRole("status").filter({373- hasText: checklistUi.lastWrite("global"),374- }).first();375- const failure = page.getByRole("status").filter({ hasText: checklistUi.writeFailed }).first();376- await global.or(failure).waitFor({377- state: "visible",378- timeout: timeoutMs,379- });380- if (await failure.isVisible()) {381- throw new Error((await failure.textContent()) ?? "Write failed");382- }383- return {384- name: "development reconnect settlement",385- status: "passed",386- detail: "The configured server exposed global settlement after reconnection.",387- };388- } catch {389- throw new Error(390- "cloud evidence failed: reconnect did not expose global settlement before the readiness deadline",391- );392- }393-}394-395-async function journeyEnvironment(396- sourceRoot: string,397- mode: "isolated-local" | "cloud-from-root",398- artifactRoot: string,399-): Promise<Record<string, string>> {400- const isolated = safeChildEnvironment();401- if (mode === "isolated-local") return await installNodeSentinel(isolated, artifactRoot);402-403- const configured = await loadEnvironment(join(sourceRoot, ".env"));404- const validation = validateEnvironment(configured);405- if (!validation.ok) throw new Error(validation.errors.join(" "));406- if (validation.mode !== "cloud-configured") {407- throw new Error(408- "cloud evidence requires the complete JAZZ_APP_ID/JAZZ_SERVER_URL pair in the root .env or process environment",409- );410- }411- const allowlisted = { ...isolated };412- for (const name of clientEnvironmentNames) {413- allowlisted[name] = configured[name]?.trim() ?? "";414- }415- for (const name of serverEnvironmentNames) allowlisted[name] = "";416- return await installNodeSentinel(allowlisted, artifactRoot);417-}418-419-async function updateItem(page: Page, body: string, nextBody: string): Promise<void> {420- const edit = page.getByRole("button", { name: checklistUi.edit(body), exact: true }).first();421- try {422- await edit.click();423- } catch (error) {424- throw new Error(`reference UI is missing accessible button "${checklistUi.edit(body)}"`, {425- cause: error,426- });427- }428- const textbox = page.getByRole("textbox", { name: checklistUi.edit(body), exact: true }).first();429- try {430- await textbox.fill(nextBody);431- } catch (error) {432- throw new Error(`reference UI is missing accessible textbox "${checklistUi.edit(body)}"`, {433- cause: error,434- });435- }436- const save = page.getByRole("button", { name: checklistUi.save(body), exact: true }).first();437- try {438- await save.click();439- } catch (error) {440- throw new Error(`reference UI is missing accessible button "${checklistUi.save(body)}"`, {441- cause: error,442- });443- }444-}445-446-async function completeItem(page: Page, body: string): Promise<void> {447- const name = checklistUi.complete(body);448- const checkboxes = page.getByRole("checkbox", { name });449- try {450- await checkboxes.first().waitFor({ state: "visible" });451- } catch (error) {452- throw new Error(453- `reference UI needs checkbox "${checklistUi.complete(body)}"`,454- { cause: error },455- );456- }457- await checkboxes.first().click();458- await page.waitForFunction((accessibleName) => {459- const matches = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]460- .filter((element) => element.getAttribute("aria-label") === accessibleName);461- return matches.length === 2 && matches.every((element) => element.checked);462- }, checklistUi.complete(body));463-}464-465-async function assertCompleted(page: Page, body: string): Promise<void> {466- const name = checklistUi.complete(body);467- const checkboxes = page.getByRole("checkbox", { name });468- await checkboxes.first().waitFor({ state: "visible" });469- await page.waitForFunction((accessibleName) => {470- const matches = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]471- .filter((element) => element.getAttribute("aria-label") === accessibleName);472- return matches.length === 2 && matches.every((element) => element.checked);473- }, name);474- assert(await checkboxes.count() === 2, `expected two completion controls for ${body}`);475- assert(476- await checkboxes.evaluateAll((elements) =>477- elements.every((element) => (element as HTMLInputElement).checked)478- ),479- `completion for ${body} did not survive reload in both islands`,480- );481-}482-483-async function deleteItem(page: Page, body: string): Promise<void> {484- const button = page.getByRole("button", { name: checklistUi.delete(body), exact: true }).first();485- try {486- await button.click();487- } catch (error) {488- throw new Error(`reference UI is missing accessible button "${checklistUi.delete(body)}"`, {489- cause: error,490- });491- }492-}493-494-async function waitForProductionPwa(page: Page, timeoutMs: number): Promise<void> {495- await page.waitForFunction(496- () => {497- return (globalThis as typeof globalThis & { __LOFI_PWA_STATE__?: string })498- .__LOFI_PWA_STATE__ === "ready";499- },500- undefined,501- { timeout: timeoutMs },502- );503- await page.waitForFunction(() => navigator.serviceWorker.controller !== null, undefined, {504- timeout: timeoutMs,505- });506-}507-508-async function waitForChecklistReady(page: Page, timeoutMs: number): Promise<void> {509- await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({510- state: "visible",511- timeout: timeoutMs,512- });513- await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({514- state: "visible",515- timeout: timeoutMs,516- });517-}518-519-async function valueFreeClientState(client: BrowserTestClient) {520- const renderedRows = await client.page.locator("[data-task-id]").count();521- const completedControls = await client.page.locator('input[type="checkbox"]:checked').count();522- return { offline: client.offline, renderedRows, completedControls };523-}524-525-async function exerciseTwoClientCloudConvergence(526- browser: Browser,527- url: string,528- islandLabels: readonly string[],529- timeoutMs: number,530- artifactRoot: string,531- redactValues: readonly string[],532-): Promise<void> {533- const fixture = await createTwoClientFixture({534- baseURL: url,535- browser,536- identity: {537- mode: "shared",538- async preparePrimary(client) {539- await waitForProductionPwa(client.page, timeoutMs);540- await waitForChecklistReady(client.page, timeoutMs);541- },542- },543- artifacts: {544- directory: join(artifactRoot, "two-client-convergence"),545- secretValues: redactValues,546- },547- });548- try {549- await Promise.all(fixture.clients.map(async (client) => {550- await waitForProductionPwa(client.page, timeoutMs);551- await waitForChecklistReady(client.page, timeoutMs);552- }));553- await addItem(fixture.first.page, islandLabels[0], convergenceTextSeedBody);554- await waitForItem(fixture.first.page, islandLabels, convergenceTextSeedBody);555- await addItem(fixture.first.page, islandLabels[0], convergenceCompletionBody);556- await waitForItem(fixture.first.page, islandLabels, convergenceCompletionBody);557- await observeReconnectSettlement(fixture.first.page, timeoutMs, true);558- await waitForItem(fixture.second.page, islandLabels, convergenceTextSeedBody);559- await waitForItem(fixture.second.page, islandLabels, convergenceCompletionBody);560-561- type ConvergenceEdit = { kind: "text" } | { kind: "completion" };562- await runConcurrentOfflineConvergence<ConvergenceEdit, BrowserTestClient>(fixture, {563- edits: [{ kind: "text" }, { kind: "completion" }],564- async ready(client) {565- await waitForProductionPwa(client.page, timeoutMs);566- await waitForItem(client.page, islandLabels, convergenceTextSeedBody);567- await waitForItem(client.page, islandLabels, convergenceCompletionBody);568- },569- async apply(client, edit) {570- if (edit.kind === "text") {571- await updateItem(client.page, convergenceTextSeedBody, convergenceEditedBody);572- } else {573- await completeItem(client.page, convergenceCompletionBody);574- }575- },576- async locallyApplied(client, edit) {577- if (edit.kind === "text") {578- await waitForItem(client.page, islandLabels, convergenceEditedBody);579- } else {580- await assertCompleted(client.page, convergenceCompletionBody);581- }582- await waitForLocalDurability(client.page);583- },584- async whilePending(current) {585- await current.first.reloadPage();586- await waitForProductionPwa(current.first.page, timeoutMs);587- await waitForItem(current.first.page, islandLabels, convergenceEditedBody);588-589- await current.second.restartPage();590- await waitForProductionPwa(current.second.page, timeoutMs);591- await waitForItem(current.second.page, islandLabels, convergenceCompletionBody);592- await assertCompleted(current.second.page, convergenceCompletionBody);593- },594- async converged(current) {595- for (const client of current.clients) {596- await waitForItem(client.page, islandLabels, convergenceEditedBody);597- await assertCompleted(client.page, convergenceCompletionBody);598- }599- },600- snapshot: valueFreeClientState,601- failureLabel: "cloud-two-client-convergence",602- });603-604- for (const body of [convergenceEditedBody, convergenceCompletionBody]) {605- await deleteItem(fixture.first.page, body);606- await waitForItemAbsent(fixture.first.page, islandLabels, body);607- await observeReconnectSettlement(fixture.first.page, timeoutMs, true);608- await waitForItemAbsent(fixture.second.page, islandLabels, body);609- }610- } catch (error) {611- await fixture.captureFailure("cloud-two-client-cleanup", valueFreeClientState).catch(() => {612- // The original convergence or cleanup failure remains authoritative.613- });614- throw error;615- } finally {616- await fixture.close();617- }618-}619-620-async function screenshotFailure(page: Page | null, path: string): Promise<void> {621- if (!page || page.isClosed()) return;622- try {623- await page.screenshot({ path, fullPage: true });624- } catch {625- // Process logs and the trace remain authoritative when the page itself is unavailable.626- }627-}628-629-function byteSequencePresent(haystack: Uint8Array, needle: Uint8Array): boolean {630- if (needle.length === 0 || haystack.length < needle.length) return false;631- outer:632- for (let offset = 0; offset <= haystack.length - needle.length; offset++) {633- for (let index = 0; index < needle.length; index++) {634- if (haystack[offset + index] !== needle[index]) continue outer;635- }636- return true;637- }638- return false;639-}640-641-async function sanitizeRetainedTrace(642- path: string,643- configuredValues: readonly string[],644-): Promise<void> {645- await sanitizeTraceArchive(646- path,647- (value) => redactDiagnosticText(value, configuredValues),648- );649- const entries = await readTraceArchive(path);650- assert(651- Object.keys(entries).every((name) => !name.startsWith("resources/")),652- "sanitized trace retained a response/source resource",653- );654- const encoder = new TextEncoder();655- for (const configured of configuredValues.filter(Boolean)) {656- const needle = encoder.encode(configured);657- assert(658- Object.values(entries).every((bytes) => !byteSequencePresent(bytes, needle)),659- "sanitized trace retained a configured environment value",660- );661- }662- const traceText = Object.entries(entries)663- .filter(([name]) => name.endsWith(".trace"))664- .map(([, bytes]) => new TextDecoder().decode(bytes))665- .join("\n");666- assert(667- !/"identity"\s*:\s*"[^"\n]+"/.test(traceText),668- "sanitized trace retained an identity-valued Playwright result",669- );670-}671-672-function commandFailure(record: CommandRecord): Error {673- return new Error(674- `${record.name} failed with exit ${record.exitCode}; rerun ${Deno.execPath()} ${675- record.args.join(" ")676- } and inspect ${record.stderrPath}`,677- );678-}679-680-async function sourceRevision(681- sourceRoot: string,682- environment: Record<string, string>,683-): Promise<string> {684- const output = await new Deno.Command("git", {685- args: ["rev-parse", "HEAD"],686- cwd: sourceRoot,687- clearEnv: true,688- env: environment,689- stdout: "piped",690- stderr: "null",691- }).output();692- return output.success ? new TextDecoder().decode(output.stdout).trim() : "unavailable";693-}694-695-export async function runJourney(options: GoldenPathOptions): Promise<JourneyReport> {696- const sourceRoot = resolve(options.projectRoot ?? Deno.cwd());697- const revisionRoot = resolve(options.sourceRevisionRoot ?? sourceRoot);698- const environmentMode = options.environmentMode ?? "isolated-local";699- const artifacts = artifactPaths(700- resolve(701- options.artifactRoot ??702- join(703- sourceRoot,704- environmentMode === "cloud-from-root"705- ? "test-results/golden-path-cloud"706- : "test-results/golden-path",707- ),708- ),709- );710- if (options.resetArtifactRoot !== false) {711- await Deno.remove(artifacts.root, { recursive: true }).catch((error) => {712- if (!(error instanceof Deno.errors.NotFound)) throw error;713- });714- }715- await Deno.mkdir(artifacts.root, { recursive: true });716-717- const startedAt = options.journeyStartedAt ?? new Date();718- const timeoutMs = options.timeoutMs ?? 60_000;719- const commands = { ...defaultCommands, ...options.commands };720- const islandLabels = options.islandLabels ?? ["North island", "South island"];721- const environment = await journeyEnvironment(sourceRoot, environmentMode, artifacts.root);722- const configuredEnvironment = await loadEnvironment(join(sourceRoot, ".env"));723- const redactValues = [724- ...new Set(725- environmentNames.flatMap((name) => [configuredEnvironment[name], environment[name]]).filter(726- Boolean,727- ),728- ),729- ];730- const commit = await sourceRevision(revisionRoot, environment);731- const commandRecords: CommandRecord[] = [...options.initialCommands ?? []];732- const assertions: JourneyAssertion[] = [];733- let projectRoot = sourceRoot;734- let browser: Browser | null = null;735- let context: BrowserContext | null = null;736- let page: Page | null = null;737- let dev: CapturedProcess | null = null;738- let preview: CapturedProcess | null = null;739- let browserVersion = "not launched";740- let devReadyMs: number | undefined;741- let firstRetainedWriteMs: number | undefined;742- let createToFirstRetainedWriteMs: number | undefined;743- let hmrMs: number[] | undefined;744- let restoreHmr: (() => Promise<void>) | null = null;745- let failure: unknown;746-747- const report: JourneyReport = {748- schemaVersion: 1,749- source: options.source,750- environmentMode: environmentMode === "cloud-from-root" ? "cloud-allowlisted" : "isolated-local",751- status: "failed",752- startedAt: startedAt.toISOString(),753- completedAt: startedAt.toISOString(),754- runtime: {755- deno: Deno.version.deno,756- typescript: Deno.version.typescript,757- v8: Deno.version.v8,758- os: Deno.build.os,759- arch: Deno.build.arch,760- browser: browserVersion,761- commit,762- },763- cacheMode: options.cacheMode ?? "existing",764- measurements: {765- developerCommandCount: options.developerCommandCount ?? (options.source === "create" ? 3 : 1),766- createMs: options.createDurationMs,767- },768- commands: commandRecords,769- assertions,770- authorBoundaryViolations: [],771- artifacts,772- };773-774- try {775- if (options.source === "checkout") {776- projectRoot = await materializeCleanProject(sourceRoot);777- commandRecords.push(778- ...await initializeFixtureGit(projectRoot, environment, artifacts.root, redactValues),779- );780- }781-782- report.authorBoundaryViolations = await scanAuthorBoundary(783- projectRoot,784- options.authorPaths ?? defaultAuthorPaths,785- );786- assert(787- report.authorBoundaryViolations.length === 0,788- `author boundary leaked framework plumbing: ${789- report.authorBoundaryViolations.map((item) => `${item.path}:${item.line} ${item.rule}`)790- .join(791- ", ",792- )793- }`,794- );795- assertions.push({796- name: "author boundary",797- status: "passed",798- detail: "No forbidden runtime, worker, transport, Workbox, or capability plumbing found.",799- });800- if (environmentMode === "cloud-from-root") {801- assert(802- clientEnvironmentNames.every((name) => Boolean(environment[name])) &&803- serverEnvironmentNames.every((name) => environment[name] === ""),804- "cloud child environment did not isolate server credentials",805- );806- assertions.push({807- name: "cloud credential isolation",808- status: "passed",809- detail:810- "The application child received only the public Jazz pair; server credentials were explicitly blank.",811- });812- }813-814- if (commands.doctor) {815- const doctor = await runCapturedCommand({816- cwd: projectRoot,817- args: taskArgs(commands.doctor),818- environment,819- artifactRoot: artifacts.root,820- name: "doctor",821- redactValues,822- });823- commandRecords.push(doctor);824- if (doctor.exitCode !== 0) throw commandFailure(doctor);825- assertions.push({826- name: "doctor preflight",827- status: "passed",828- detail: "Generated diagnostics completed without starting the application.",829- });830- }831-832- try {833- browser = await chromium.launch({ headless: true });834- } catch (error) {835- throw new Error(836- "Chromium is unavailable; install the pinned browser through the repository's explicit Deno browser-install task.",837- { cause: error },838- );839- }840- browserVersion = browser.version();841- report.runtime.browser = browserVersion;842- context = await browser.newContext();843- await context.tracing.start({ screenshots: false, snapshots: false, sources: false });844- page = await context.newPage();845-846- const port = reserveLocalPort();847- const devStarted = performance.now();848- dev = startReadyProcess({849- cwd: projectRoot,850- args: taskArgs(commands.dev, ["--host", "127.0.0.1", "--port", String(port)]),851- environment,852- artifactRoot: artifacts.root,853- name: "dev",854- readyPattern: /(http:\/\/(?:127\.0\.0\.1|localhost):\d+\/?)/,855- readyCheck: probeHttpReady,856- timeoutMs,857- redactValues,858- });859- const devUrl = await dev.ready;860- devReadyMs = Math.round(performance.now() - devStarted);861- await page.goto(devUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });862- await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({863- state: "visible",864- });865- await assertRuntimeCardinality(page, islandLabels);866- await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({867- state: "visible",868- });869- const inspector = page.locator('[data-lofi-inspector="lofi-development-inspector"]');870- await inspector.waitFor({ state: "attached" });871- const inspectorSnapshot = await page.evaluate(async () => {872- const bridge = (globalThis as typeof globalThis & {873- __LOFI_INSPECTOR__?: { readSnapshot(): Promise<unknown> };874- }).__LOFI_INSPECTOR__;875- if (!bridge) throw new Error("development inspector bridge is unavailable");876- return await bridge.readSnapshot();877- });878- const serializedInspector = JSON.stringify(inspectorSnapshot);879- assert(880- serializedInspector.includes("live detail unavailable") ||881- serializedInspector.includes("not configured"),882- "inspector must name unavailable transport precision",883- );884- assert(885- !/secret|token|password/i.test(serializedInspector),886- "inspector snapshot exposed secrets",887- );888- if (environmentMode === "isolated-local") {889- assert(890- await page.getByRole("button", { name: "Transport pause unavailable in local-only" })891- .isDisabled(),892- "local-only inspector must disable cloud transport controls",893- );894- }895- assertions.push({896- name: "development inspector",897- status: "passed",898- detail:899- "The development-only surface exposed value-free storage, durability, runtime, and explicitly unavailable vendor signals.",900- });901- devReadyMs = Math.round(performance.now() - devStarted);902- assertions.push({903- name: "development readiness",904- status: "passed",905- detail: `Hydrated author UI and the persistent runtime were usable in ${devReadyMs}ms.`,906- });907-908- await addItem(page, islandLabels[0], retainedBody);909- await waitForItem(page, islandLabels, retainedBody);910- await waitForLocalDurability(page);911- firstRetainedWriteMs = Math.round(performance.now() - devStarted);912- if (options.journeyStartedPerformanceMs !== undefined) {913- createToFirstRetainedWriteMs = Math.round(914- performance.now() - options.journeyStartedPerformanceMs,915- );916- }917- await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });918- await waitForItem(page, islandLabels, retainedBody);919- await assertRuntimeCardinality(page, islandLabels);920- assertions.push({921- name: "retained development write",922- status: "passed",923- detail:924- "The local write survived reload and both islands kept one shared runtime subscription.",925- });926-927- await restartClientThroughInspector(page, islandLabels, retainedBody, timeoutMs);928- assertions.push({929- name: "inspector client restart",930- status: "passed",931- detail: "The development-only client restart retained data and restored runtime cardinality.",932- });933-934- const hmr = await exerciseHmr(935- page,936- projectRoot,937- options.hmrPath ??938- (options.source === "create"939- ? "src/styles/global.css"940- : "apps/reference/src/styles/global.css"),941- islandLabels,942- retainedBody,943- timeoutMs,944- );945- hmrMs = hmr.samples;946- restoreHmr = hmr.restore;947- assertions.push({948- name: "HMR state and cardinality",949- status: "passed",950- detail:951- `Five observable style edits retained data with one client, two consumers, and one vendor subscription (${952- hmrMs.join(", ")953- }ms).`,954- });955-956- await updateItem(page, retainedBody, updatedBody);957- await waitForItem(page, islandLabels, updatedBody);958- await completeItem(page, updatedBody);959- await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });960- await waitForItem(page, islandLabels, updatedBody);961- await assertCompleted(page, updatedBody);962-963- await addItem(page, islandLabels[0], disposableBody);964- await waitForItem(page, islandLabels, disposableBody);965- if (environmentMode === "cloud-from-root") {966- await observeReconnectSettlement(page, timeoutMs, true);967- }968- await deleteItem(page, disposableBody);969- await waitForItemAbsent(page, islandLabels, disposableBody);970- if (environmentMode === "cloud-from-root") {971- const online = await observeReconnectSettlement(page, timeoutMs, true);972- assertions.push({973- ...online,974- name: "development online settlement",975- detail:976- "The online insert and delete reached global durability before the inspector paused transport.",977- });978- }979- assertions.push({980- name: "accessible CRUD",981- status: "passed",982- detail: "Create, update, complete, and delete worked through named author-facing controls.",983- });984-985- if (environmentMode === "cloud-from-root") {986- await setReferenceConnection(page, false);987- } else {988- await context.setOffline(true);989- }990- await addItem(page, islandLabels[1], offlineDevelopmentBody);991- await waitForItem(page, islandLabels, offlineDevelopmentBody);992- await waitForLocalDurability(page);993- if (environmentMode === "cloud-from-root") await setReferenceConnection(page, true);994- else await context.setOffline(false);995- assertions.push(996- await observeReconnectSettlement(page, timeoutMs, environmentMode === "cloud-from-root"),997- );998- await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });999- await waitForItem(page, islandLabels, offlineDevelopmentBody);1000- assertions.push({1001- name: "development offline write",1002- status: "passed",1003- detail: environmentMode === "cloud-from-root"1004- ? "A loaded page accepted a local write while Jazz transport was paused and retained it after transport resumed."1005- : "A loaded page accepted a local write offline and retained it after network return.",1006- });1007-1008- if (environmentMode === "isolated-local") {1009- await exerciseReplicaClear(browser, devUrl, islandLabels, timeoutMs);1010- assertions.push({1011- name: "inspector replica clear",1012- status: "passed",1013- detail:1014- "A confirmation-gated clear removed an isolated OPFS replica while preserving its in-memory-compared identity state.",1015- });1016- }1017-1018- const devStderrPath = dev.stderrPath;1019- await dev.stop();1020- dev = null;1021- const devStderr = await Deno.readTextFile(devStderrPath);1022- assert(1023- !devStderr.includes("error: development server stopped"),1024- `normal development shutdown printed a fatal diagnostic; inspect ${devStderrPath}`,1025- );1026- assertions.push({1027- name: "development shutdown",1028- status: "passed",1029- detail:1030- "The generated development command stopped on the harness signal without a false failure.",1031- });1032- if (restoreHmr) await restoreHmr();1033- restoreHmr = null;1034- for (1035- const [name, task] of [1036- ["check", commands.check],1037- ["test", commands.test],1038- ["build", commands.build],1039- ] as const1040- ) {1041- const record = await runCapturedCommand({1042- cwd: projectRoot,1043- args: taskArgs(task),1044- environment,1045- artifactRoot: artifacts.root,1046- name,1047- redactValues,1048- });1049- commandRecords.push(record);1050- if (record.exitCode !== 0) throw commandFailure(record);1051- }1052- assertions.push({1053- name: "public verification commands",1054- status: "passed",1055- detail: "check, test, and build completed through Deno tasks.",1056- });1057- const productionRoot = join(1058- projectRoot,1059- options.source === "create" ? "dist" : "apps/reference/dist",1060- );1061- const inspectorFiles = await inspectorMarkerFiles(productionRoot);1062- assert(1063- inspectorFiles.length === 0,1064- `production build contains development inspector markers: ${inspectorFiles.join(", ")}`,1065- );1066- assertions.push({1067- name: "production inspector exclusion",1068- status: "passed",1069- detail: "The production bundle contains no development-inspector marker.",1070- });1071-1072- preview = startReadyProcess({1073- cwd: projectRoot,1074- args: taskArgs(commands.preview, ["--port", String(port)]),1075- environment,1076- artifactRoot: artifacts.root,1077- name: "preview",1078- readyPattern: /(http:\/\/127\.0\.0\.1:\d+\/)/,1079- readyCheck: probeHttpReady,1080- timeoutMs,1081- redactValues,1082- });1083- const previewUrl = await preview.ready;1084- assert(new URL(previewUrl).origin === new URL(devUrl).origin, "preview must reuse dev origin");1085- await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });1086- assert(1087- await page.locator('[data-lofi-inspector="lofi-development-inspector"]').count() === 0,1088- "production preview mounted the development inspector",1089- );1090- await waitForItem(page, islandLabels, updatedBody);1091- await waitForProductionPwa(page, timeoutMs);1092- await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });1093- await waitForProductionPwa(page, timeoutMs);1094-1095- await context.setOffline(true);1096- await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });1097- await waitForItem(page, islandLabels, updatedBody);1098- await addItem(page, islandLabels[0], offlineProductionBody);1099- await waitForItem(page, islandLabels, offlineProductionBody);1100- await waitForLocalDurability(page);1101- await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });1102- await waitForItem(page, islandLabels, offlineProductionBody);1103- await context.setOffline(false);1104- if (environmentMode === "cloud-from-root") {1105- await updateItem(page, offlineProductionBody, offlineProductionSyncedBody);1106- await waitForItem(page, islandLabels, offlineProductionSyncedBody);1107- assertions.push(await observeReconnectSettlement(page, timeoutMs, true));1108- }1109- await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });1110- await waitForItem(1111- page,1112- islandLabels,1113- environmentMode === "cloud-from-root" ? offlineProductionSyncedBody : offlineProductionBody,1114- );1115- assertions.push({1116- name: "production offline cold start",1117- status: "passed",1118- detail: "The service-worker shell and OPFS data survived cold offline reload and editing.",1119- });1120-1121- if (environmentMode === "cloud-from-root") {1122- await exerciseTwoClientCloudConvergence(1123- browser,1124- previewUrl,1125- islandLabels,1126- timeoutMs,1127- artifacts.root,1128- redactValues,1129- );1130- assertions.push({1131- name: "cloud two-client convergence",1132- status: "passed",1133- detail:1134- "Two isolated OPFS replicas shared one in-memory-restored identity, retained orthogonal offline edits through reload/page restart, converged after reconnect, and cleaned up globally.",1135- });1136- }1137-1138- if (environmentMode === "cloud-from-root") {1139- for (const body of [updatedBody, offlineDevelopmentBody, offlineProductionSyncedBody]) {1140- await deleteItem(page, body);1141- await waitForItemAbsent(page, islandLabels, body);1142- assertions.push(await observeReconnectSettlement(page, timeoutMs, true));1143- }1144- assertions.push({1145- name: "cloud fixture cleanup",1146- status: "passed",1147- detail:1148- "All golden-path rows were deleted through the public UI and the delete mutations reached global durability before the throwaway browser identity closed.",1149- });1150- }1151-1152- report.status = "passed";1153- } catch (error) {1154- failure = error;1155- assertions.push({1156- name: "golden journey",1157- status: "failed",1158- detail: error instanceof Error ? error.message : String(error),1159- });1160- await screenshotFailure(page, artifacts.screenshot);1161- } finally {1162- if (context) {1163- await context.setOffline(false).catch(() => undefined);1164- try {1165- await context.tracing.stop({ path: artifacts.trace });1166- await sanitizeRetainedTrace(artifacts.trace, redactValues);1167- assertions.push({1168- name: "trace artifact safety",1169- status: "passed",1170- detail:1171- "The snapshot-free retained trace omitted response/source resources, identity-valued results, and configured environment values.",1172- });1173- } catch (error) {1174- await Deno.remove(artifacts.trace).catch(() => undefined);1175- const traceFailure = new Error(1176- `retained trace could not be made value-free: ${1177- error instanceof Error ? error.message : String(error)1178- }`,1179- { cause: error },1180- );1181- if (!failure) failure = traceFailure;1182- report.status = "failed";1183- assertions.push({1184- name: "trace artifact safety",1185- status: "failed",1186- detail: traceFailure.message,1187- });1188- }1189- }1190- await preview?.stop().catch(() => undefined);1191- await dev?.stop().catch(() => undefined);1192- await restoreHmr?.().catch(() => undefined);1193- await context?.close().catch(() => undefined);1194- await browser?.close().catch(() => undefined);1195- if (options.source === "checkout" && projectRoot !== sourceRoot) {1196- await Deno.remove(resolve(projectRoot, ".."), { recursive: true }).catch(() => {1197- assertions.push({1198- name: "clean checkout cleanup",1199- status: "blocked",1200- detail: `Temporary checkout cleanup failed for ${projectRoot}.`,1201- });1202- });1203- }1204- report.completedAt = new Date().toISOString();1205- report.measurements.devReadyMs = devReadyMs;1206- report.measurements.firstRetainedWriteMs = firstRetainedWriteMs;1207- report.measurements.createToFirstRetainedWriteMs = createToFirstRetainedWriteMs;1208- report.measurements.hmrMs = hmrMs;1209- if (hmrMs?.length) {1210- const sorted = [...hmrMs].sort((left, right) => left - right);1211- report.measurements.hmrMedianMs = sorted[Math.floor(sorted.length / 2)];1212- report.measurements.hmrSlowestMs = sorted.at(-1);1213- }1214- report.runtime.browser = browserVersion;1215- await writeJourneyReport(report);1216- }1217-1218- if (failure) throw failure;1219- return report;1220-}
D tools/golden_path_checkout.ts +0 -18
D tools/golden_path_core.ts +0 -707
@@ -1,707 +0,0 @@
1-import { basename, dirname, join, relative, resolve } from "node:path";2-import { environmentNames } from "./env_contract.ts";3-4-export type JourneySource = "checkout" | "create";5-6-export type CommandRecord = {7- name: string;8- args: string[];9- durationMs: number;10- exitCode: number;11- stdoutPath: string;12- stderrPath: string;13-};14-15-export type JourneyAssertion = {16- name: string;17- status: "passed" | "failed" | "blocked";18- detail: string;19-};20-21-export type AuthorBoundaryViolation = {22- rule: string;23- path: string;24- line: number;25- excerpt: string;26-};27-28-export type JourneyReport = {29- schemaVersion: 1;30- source: JourneySource;31- environmentMode: "isolated-local" | "cloud-allowlisted";32- status: "passed" | "failed";33- startedAt: string;34- completedAt: string;35- runtime: {36- deno: string;37- typescript: string;38- v8: string;39- os: string;40- arch: string;41- browser: string;42- commit: string;43- };44- cacheMode: "existing" | "cold";45- measurements: {46- developerCommandCount: number;47- createMs?: number;48- createToFirstRetainedWriteMs?: number;49- devReadyMs?: number;50- firstRetainedWriteMs?: number;51- hmrMs?: number[];52- hmrMedianMs?: number;53- hmrSlowestMs?: number;54- };55- commands: CommandRecord[];56- assertions: JourneyAssertion[];57- authorBoundaryViolations: AuthorBoundaryViolation[];58- artifacts: {59- root: string;60- report: string;61- trace: string;62- screenshot: string;63- };64-};65-66-export type BoundaryRule = {67- id: string;68- pattern: RegExp;69- allow?: (path: string) => boolean;70-};71-72-export const defaultBoundaryRules: readonly BoundaryRule[] = [73- {74- id: "raw-jazz-runtime-import",75- pattern: /(?:from\s+|import\s*\()["']jazz-tools(?:\/[^"']*)?["']/,76- // The M1 contract still explicitly permits the pinned Jazz schema DSL in schema.ts.77- allow: (path) => basename(path) === "schema.ts" || basename(path) === "permissions.ts",78- },79- {80- id: "raw-jazz-client-api",81- pattern: /\b(?:BrowserAuthSecretStore|createDb|JazzProvider|useJazzContext)\b/,82- },83- {84- id: "worker-plumbing",85- pattern: /\b(?:SharedWorker|navigator\.serviceWorker|new\s+Worker)\b/,86- },87- {88- id: "transport-plumbing",89- pattern: /\b(?:JAZZ_SERVER_URL|VITE_JAZZ_[A-Z0-9_]*|transportUrl)\b/,90- },91- { id: "workbox-plumbing", pattern: /\bworkbox\b/i },92- {93- id: "browser-capability-branch",94- pattern: /\b(?:navigator\.storage|navigator\.locks|isSecureContext)\b/,95- },96-];97-98-const ignoredDirectoryNames = new Set([99- ".astro",100- ".git",101- ".playwright-cli",102- ".vite",103- "coverage",104- "dist",105- "node_modules",106- "output",107- "playwright-report",108- "test-results",109-]);110-111-const childEnvironmentAllowlist = [112- "CI",113- "DENO_DIR",114- "GITHUB_ACTIONS",115- "LANG",116- "LC_ALL",117- "NIX_SSL_CERT_FILE",118- "NO_PROXY",119- "PATH",120- "PLAYWRIGHT_BROWSERS_PATH",121- "RUNNER_ARCH",122- "RUNNER_OS",123- "SSL_CERT_FILE",124- "TEMP",125- "TMP",126- "TMPDIR",127-] as const;128-129-function assertWithin(root: string, path: string) {130- const child = relative(resolve(root), resolve(path));131- if (child === "" || (!child.startsWith("..") && !child.startsWith("/"))) return;132- throw new Error(`path escapes project root: ${path}`);133-}134-135-function shouldExclude(name: string, isDirectory: boolean, relativePath: string): boolean {136- if (isDirectory && (ignoredDirectoryNames.has(name) || name.startsWith(".nzip-"))) return true;137- // The M2 reference application supersedes this ignored M1 working artifact. A working-tree copy138- // must not accidentally resurrect it just because another agent is moving tracked paths.139- if (isDirectory && relativePath === join("apps", "prototype")) return true;140- if (name === ".env.example") return false;141- if (name === ".env" || name.startsWith(".env.")) return true;142- return name.endsWith(".sqlite") || name.includes(".sqlite-");143-}144-145-async function copyDirectory(146- sourceRoot: string,147- source: string,148- destination: string,149-): Promise<void> {150- await Deno.mkdir(destination, { recursive: true });151- const entries: Deno.DirEntry[] = [];152- for await (const entry of Deno.readDir(source)) entries.push(entry);153- entries.sort((left, right) => left.name.localeCompare(right.name));154-155- for (const entry of entries) {156- const from = join(source, entry.name);157- const to = join(destination, entry.name);158- const displayPath = relative(sourceRoot, from);159- if (shouldExclude(entry.name, entry.isDirectory, displayPath)) continue;160- if (entry.isDirectory) {161- await copyDirectory(sourceRoot, from, to);162- } else if (entry.isFile) {163- await Deno.copyFile(from, to);164- } else if (entry.isSymlink) {165- throw new Error(`clean checkout refuses symlink: ${relative(source, from)}`);166- }167- }168-}169-170-async function trackedFiles(source: string): Promise<string[] | null> {171- const rootOutput = await new Deno.Command("git", {172- args: ["-C", source, "rev-parse", "--show-toplevel"],173- stdout: "piped",174- stderr: "null",175- }).output();176- if (!rootOutput.success) return null;177- const repositoryRoot = new TextDecoder().decode(rootOutput.stdout).trim();178- if (resolve(repositoryRoot) !== resolve(source)) return null;179-180- const filesOutput = await new Deno.Command("git", {181- args: ["-C", source, "ls-files", "-z"],182- stdout: "piped",183- stderr: "null",184- }).output();185- if (!filesOutput.success) return null;186- return new TextDecoder().decode(filesOutput.stdout).split("\0").filter(Boolean).sort();187-}188-189-async function copyTrackedFiles(190- source: string,191- destination: string,192- files: readonly string[],193-): Promise<void> {194- await Deno.mkdir(destination, { recursive: true });195- for (const displayPath of files) {196- const segments = displayPath.split(/[\\/]/);197- if (198- segments.some((name, index) =>199- shouldExclude(name, index < segments.length - 1, segments.slice(0, index + 1).join("/"))200- )201- ) continue;202- const from = join(source, displayPath);203- const to = join(destination, displayPath);204- const stat = await Deno.lstat(from);205- if (stat.isSymlink) throw new Error(`clean checkout refuses symlink: ${displayPath}`);206- if (!stat.isFile) continue;207- await Deno.mkdir(dirname(to), { recursive: true });208- await Deno.copyFile(from, to);209- }210-}211-212-export async function materializeCleanProject(213- sourceRoot: string,214- destinationRoot?: string,215-): Promise<string> {216- const source = resolve(sourceRoot);217- const destination = destinationRoot218- ? resolve(destinationRoot)219- : join(await Deno.makeTempDir({ prefix: "lofi-golden-" }), "project");220- if (source === destination) throw new Error("clean project destination must differ from source");221- const files = await trackedFiles(source);222- if (files) await copyTrackedFiles(source, destination, files);223- else await copyDirectory(source, source, destination);224- return destination;225-}226-227-export function safeChildEnvironment(228- source: Record<string, string> = Deno.env.toObject(),229-): Record<string, string> {230- const environment: Record<string, string> = {};231- for (const name of childEnvironmentAllowlist) {232- const value = source[name];233- if (value) environment[name] = value;234- }235- environment.CI = "1";236- environment.NO_COLOR = "1";237- // Explicit empties prevent a parent shell's sync credentials from influencing local mode.238- for (const name of environmentNames) environment[name] = "";239- return environment;240-}241-242-export async function installNodeSentinel(243- environment: Record<string, string>,244- artifactRoot: string,245-): Promise<Record<string, string>> {246- if (Deno.build.os === "windows") return { ...environment };247- const directory = join(artifactRoot, "node-sentinel");248- const executable = join(directory, "node");249- await Deno.mkdir(directory, { recursive: true });250- await Deno.writeTextFile(251- executable,252- '#!/bin/sh\necho "error: hidden Node invocation is forbidden by the lofi golden path" >&2\nexit 97\n',253- );254- await Deno.chmod(executable, 0o755);255- return {256- ...environment,257- PATH: environment.PATH ? `${directory}:${environment.PATH}` : directory,258- };259-}260-261-async function sourceFiles(path: string): Promise<string[]> {262- const stat = await Deno.stat(path);263- if (stat.isFile) return [path];264- const files: string[] = [];265- const entries: Deno.DirEntry[] = [];266- for await (const entry of Deno.readDir(path)) entries.push(entry);267- entries.sort((left, right) => left.name.localeCompare(right.name));268- for (const entry of entries) {269- const child = join(path, entry.name);270- if (entry.isDirectory) files.push(...await sourceFiles(child));271- else if (entry.isFile && /\.(?:astro|css|js|jsx|ts|tsx)$/.test(entry.name)) files.push(child);272- }273- return files;274-}275-276-export async function scanAuthorBoundary(277- projectRoot: string,278- authorPaths: readonly string[],279- rules: readonly BoundaryRule[] = defaultBoundaryRules,280-): Promise<AuthorBoundaryViolation[]> {281- const root = resolve(projectRoot);282- const files: string[] = [];283- for (const authorPath of authorPaths) {284- const path = resolve(root, authorPath);285- assertWithin(root, path);286- try {287- files.push(...await sourceFiles(path));288- } catch (error) {289- if (!(error instanceof Deno.errors.NotFound)) throw error;290- }291- }292-293- const violations: AuthorBoundaryViolation[] = [];294- for (const file of [...new Set(files)].sort()) {295- const displayPath = relative(root, file);296- const lines = (await Deno.readTextFile(file)).split("\n");297- for (let index = 0; index < lines.length; index += 1) {298- for (const rule of rules) {299- rule.pattern.lastIndex = 0;300- if (rule.allow?.(displayPath) || !rule.pattern.test(lines[index])) continue;301- violations.push({302- rule: rule.id,303- path: displayPath,304- line: index + 1,305- excerpt: lines[index].trim().slice(0, 160),306- });307- }308- }309- }310- return violations;311-}312-313-export async function withDeadline<T>(314- operation: Promise<T>,315- timeoutMs: number,316- description: string,317-): Promise<T> {318- let timeout: ReturnType<typeof setTimeout> | undefined;319- const deadline = new Promise<never>((_, reject) => {320- timeout = setTimeout(321- () => reject(new Error(`${description} did not become ready within ${timeoutMs}ms`)),322- timeoutMs,323- );324- });325- try {326- return await Promise.race([operation, deadline]);327- } finally {328- clearTimeout(timeout);329- }330-}331-332-export type CapturedProcess = {333- child: Deno.ChildProcess;334- ready: Promise<string>;335- completed: Promise<Deno.CommandStatus>;336- stdoutPath: string;337- stderrPath: string;338- stop(): Promise<void>;339-};340-341-function isAlreadyTerminated(error: unknown): boolean {342- return error instanceof Deno.errors.NotFound ||343- (error instanceof TypeError && error.message.includes("already terminated"));344-}345-346-async function captureLines(347- stream: ReadableStream<Uint8Array>,348- path: string,349- onLine: (line: string) => void,350- redact: (text: string) => string,351-): Promise<void> {352- const file = await Deno.open(path, { create: true, truncate: true, write: true });353- const writer = file.writable.getWriter();354- const decoder = new TextDecoder();355- const encoder = new TextEncoder();356- let pending = "";357- try {358- for await (const chunk of stream) {359- pending += decoder.decode(chunk, { stream: true });360- const lines = pending.split(/\r?\n/);361- pending = lines.pop() ?? "";362- for (const line of lines) {363- const redacted = redact(line);364- await writer.write(encoder.encode(`${redacted}\n`));365- onLine(redacted);366- }367- }368- pending += decoder.decode();369- if (pending) {370- const redacted = redact(pending);371- await writer.write(encoder.encode(redacted));372- onLine(redacted);373- }374- } finally {375- await writer.close();376- }377-}378-379-export function redactText(text: string, values: readonly string[]): string {380- return createRedactor(values)(text);381-}382-383-function createRedactor(values: readonly string[]): (text: string) => string {384- const candidates = [...new Set(values.map((value) => value.trim()).filter(Boolean))]385- .sort((left, right) => right.length - left.length);386- return (text) => {387- let redacted = text;388- for (const value of candidates) redacted = redacted.split(value).join("[redacted]");389- return redacted;390- };391-}392-393-function delay(milliseconds: number): Promise<void> {394- return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));395-}396-397-export async function probeHttpReady(url: string): Promise<boolean> {398- try {399- const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });400- await response.body?.cancel();401- return response.status >= 200 && response.status < 400;402- } catch {403- return false;404- }405-}406-407-async function descendantProcessIds(rootPid: number): Promise<number[]> {408- if (Deno.build.os === "windows") return [];409- const output = await new Deno.Command("ps", {410- args: ["-axo", "pid=,ppid="],411- stdout: "piped",412- stderr: "null",413- }).output();414- if (!output.success) return [];415- const children = new Map<number, number[]>();416- for (const line of new TextDecoder().decode(output.stdout).split("\n")) {417- const [pidText, parentText] = line.trim().split(/\s+/);418- const pid = Number(pidText);419- const parent = Number(parentText);420- if (!Number.isInteger(pid) || !Number.isInteger(parent)) continue;421- const siblings = children.get(parent) ?? [];422- siblings.push(pid);423- children.set(parent, siblings);424- }425- const descendants: number[] = [];426- const visit = (pid: number) => {427- for (const child of children.get(pid) ?? []) visit(child);428- if (pid !== rootPid) descendants.push(pid);429- };430- visit(rootPid);431- return descendants;432-}433-434-function signalProcess(pid: number, signal: Deno.Signal): void {435- try {436- Deno.kill(pid, signal);437- } catch (error) {438- if (!isAlreadyTerminated(error)) throw error;439- }440-}441-442-async function waitForPortRelease(url: string, timeoutMs = 2_000): Promise<void> {443- const parsed = new URL(url);444- const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));445- const hostname = parsed.hostname;446- const started = performance.now();447- while (performance.now() - started < timeoutMs) {448- try {449- const connection = await Deno.connect({ hostname, port });450- connection.close();451- await delay(50);452- } catch {453- return;454- }455- }456- throw new Error(`process shutdown left ${parsed.origin} accepting connections`);457-}458-459-export function startReadyProcess(options: {460- executable?: string;461- cwd: string;462- args: string[];463- environment: Record<string, string>;464- artifactRoot: string;465- name: string;466- readyPattern: RegExp;467- readyCheck?: (value: string) => Promise<boolean>;468- timeoutMs: number;469- redactValues?: readonly string[];470-}): CapturedProcess {471- const stdoutPath = join(options.artifactRoot, `${options.name}.stdout.log`);472- const stderrPath = join(options.artifactRoot, `${options.name}.stderr.log`);473- const redact = createRedactor(options.redactValues ?? []);474- const child = new Deno.Command(options.executable ?? Deno.execPath(), {475- args: options.args,476- cwd: options.cwd,477- clearEnv: true,478- env: options.environment,479- stdin: "null",480- stdout: "piped",481- stderr: "piped",482- }).spawn();483-484- let resolveReady!: (value: string) => void;485- let rejectReady!: (error: Error) => void;486- let settled = false;487- let advertisedValue: string | null = null;488- let terminalStatus: Deno.CommandStatus | null = null;489- const stderrTail: string[] = [];490- const knownDescendants = new Set<number>();491- const rememberDescendants = async () => {492- for (const pid of await descendantProcessIds(child.pid)) knownDescendants.add(pid);493- };494- const diagnostics = (reason: string, status = terminalStatus): Error => {495- const processState = status496- ? `exited with ${status.code}${status.signal ? ` (${status.signal})` : ""}`497- : "is still running";498- const tail = stderrTail.length > 0 ? stderrTail.join("\n") : "(empty)";499- return new Error(500- `${options.name} ${reason}; process ${processState}; stderr tail:\n${tail}\nretained stderr: ${stderrPath}`,501- );502- };503- const readiness = new Promise<string>((resolveReadyPromise, rejectReadyPromise) => {504- resolveReady = resolveReadyPromise;505- rejectReady = rejectReadyPromise;506- });507- const verifyAdvertisedValue = async (value: string) => {508- if (!options.readyCheck) {509- if (!settled) {510- settled = true;511- resolveReady(value);512- }513- return;514- }515- while (!settled) {516- if (await options.readyCheck(value)) {517- settled = true;518- resolveReady(value);519- return;520- }521- await delay(50);522- }523- };524- const inspectLine = (line: string) => {525- options.readyPattern.lastIndex = 0;526- const match = options.readyPattern.exec(line);527- if (match && !advertisedValue) {528- advertisedValue = match[1] ?? match[0];529- void rememberDescendants();530- void verifyAdvertisedValue(advertisedValue).catch((error) => {531- if (!settled) {532- settled = true;533- rejectReady(error instanceof Error ? error : new Error(String(error)));534- }535- });536- }537- };538- const stdout = captureLines(child.stdout, stdoutPath, inspectLine, redact);539- const stderr = captureLines(child.stderr, stderrPath, (line) => {540- stderrTail.push(line);541- if (stderrTail.length > 20) stderrTail.shift();542- inspectLine(line);543- }, redact);544- const completed = (async () => {545- const status = await child.status;546- terminalStatus = status;547- if (!settled) {548- // Give the capture loops one turn to drain buffered stderr without letting an549- // inherited grandchild pipe delay the exit diagnosis indefinitely.550- await Promise.race([Promise.all([stdout, stderr]), delay(100)]);551- settled = true;552- rejectReady(diagnostics("exited before its advertised URL accepted requests", status));553- }554- await Promise.all([stdout, stderr]);555- return status;556- })();557- const ready = withDeadline(readiness, options.timeoutMs, `${options.name} URL`).catch((error) => {558- if (!settled) settled = true;559- if (error instanceof Error && error.message.includes("did not become ready")) {560- throw diagnostics(561- advertisedValue562- ? "printed a URL that never accepted HTTP requests before the readiness deadline"563- : "did not print a usable URL before the readiness deadline",564- );565- }566- throw error;567- });568-569- return {570- child,571- ready,572- completed,573- stdoutPath,574- stderrPath,575- async stop() {576- await rememberDescendants();577- for (const pid of knownDescendants) signalProcess(pid, "SIGTERM");578- try {579- child.kill("SIGTERM");580- } catch (error) {581- if (!isAlreadyTerminated(error)) throw error;582- }583- try {584- await withDeadline(completed, 5_000, `${options.name} shutdown`);585- } catch {586- await rememberDescendants();587- for (const pid of knownDescendants) signalProcess(pid, "SIGKILL");588- try {589- child.kill("SIGKILL");590- } catch (error) {591- if (!isAlreadyTerminated(error)) throw error;592- }593- await completed;594- }595- if (advertisedValue?.startsWith("http://") || advertisedValue?.startsWith("https://")) {596- await waitForPortRelease(advertisedValue);597- }598- },599- };600-}601-602-export async function runCapturedCommand(options: {603- executable?: string;604- cwd: string;605- args: string[];606- environment: Record<string, string>;607- artifactRoot: string;608- name: string;609- redactValues?: readonly string[];610-}): Promise<CommandRecord> {611- const stdoutPath = join(options.artifactRoot, `${options.name}.stdout.log`);612- const stderrPath = join(options.artifactRoot, `${options.name}.stderr.log`);613- const started = performance.now();614- const output = await new Deno.Command(options.executable ?? Deno.execPath(), {615- args: options.args,616- cwd: options.cwd,617- clearEnv: true,618- env: options.environment,619- stdin: "null",620- stdout: "piped",621- stderr: "piped",622- }).output();623- const decoder = new TextDecoder();624- await Promise.all([625- Deno.writeTextFile(626- stdoutPath,627- redactText(decoder.decode(output.stdout), options.redactValues ?? []),628- ),629- Deno.writeTextFile(630- stderrPath,631- redactText(decoder.decode(output.stderr), options.redactValues ?? []),632- ),633- ]);634- return {635- name: options.name,636- args: options.args,637- durationMs: Math.round(performance.now() - started),638- exitCode: output.code,639- stdoutPath,640- stderrPath,641- };642-}643-644-export async function initializeFixtureGit(645- projectRoot: string,646- environment: Record<string, string>,647- artifactRoot: string,648- redactValues: readonly string[] = [],649-): Promise<CommandRecord[]> {650- // Deno does not proxy arbitrary git subcommands. Execute git directly, but retain the same651- // structured evidence as every public Deno command.652- const records: CommandRecord[] = [];653- for (654- const [name, args] of [655- ["fixture-git-init", ["init", "-q"]],656- ["fixture-git-add", ["add", "-A"]],657- [658- "fixture-git-commit",659- [660- "-c",661- "user.name=lofi golden path",662- "-c",663- "user.email=golden-path@invalid",664- "-c",665- "commit.gpgsign=false",666- "commit",667- "-qm",668- "clean checkout fixture",669- ],670- ],671- ] as const672- ) {673- const record = await runCapturedCommand({674- executable: "git",675- args: [...args],676- cwd: projectRoot,677- name,678- environment,679- artifactRoot,680- redactValues,681- });682- records.push(record);683- if (record.exitCode !== 0) throw new Error(`${name} failed; see ${record.stderrPath}`);684- }685- return records;686-}687-688-export function reserveLocalPort(): number {689- const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });690- const address = listener.addr as Deno.NetAddr;691- listener.close();692- return address.port;693-}694-695-export function artifactPaths(root: string) {696- return {697- root,698- report: join(root, "report.json"),699- trace: join(root, "trace.zip"),700- screenshot: join(root, "failure.png"),701- };702-}703-704-export async function writeJourneyReport(report: JourneyReport): Promise<void> {705- await Deno.mkdir(dirname(report.artifacts.report), { recursive: true });706- await Deno.writeTextFile(report.artifacts.report, `${JSON.stringify(report, null, 2)}\n`);707-}
D tools/golden_path_core_test.ts +0 -340
@@ -1,340 +0,0 @@
1-import { join, resolve } from "node:path";2-import { assert } from "./assert.ts";3-import {4- installNodeSentinel,5- materializeCleanProject,6- probeHttpReady,7- redactText,8- reserveLocalPort,9- runCapturedCommand,10- safeChildEnvironment,11- scanAuthorBoundary,12- startReadyProcess,13- withDeadline,14-} from "./golden_path_core.ts";15-16-async function exists(path: string): Promise<boolean> {17- try {18- await Deno.stat(path);19- return true;20- } catch (error) {21- if (error instanceof Deno.errors.NotFound) return false;22- throw error;23- }24-}25-26-async function workspaceTemp(prefix: string): Promise<string> {27- return await Deno.makeTempDir({ dir: ".", prefix: `.lofi-${prefix}-` });28-}29-30-async function removeWorkspaceTemp(path: string): Promise<void> {31- await Deno.remove(path, { recursive: true }).catch((error) => {32- if (!(error instanceof Deno.errors.NotFound)) throw error;33- });34-}35-36-Deno.test("safe child environment removes ambient application configuration", () => {37- const environment = safeChildEnvironment({38- PATH: "/test/bin",39- DENO_DIR: "/test/deno",40- JAZZ_APP_ID: "public-id",41- JAZZ_SERVER_URL: "https://sync.invalid",42- JAZZ_ADMIN_SECRET: "admin-secret",43- BACKEND_SECRET: "backend-secret",44- UNRELATED_SECRET: "also-secret",45- });46-47- assert(environment.PATH === "/test/bin", "PATH should remain available to Deno tasks");48- assert(environment.DENO_DIR === "/test/deno", "the explicit Deno cache should remain available");49- assert(environment.CI === "1", "clean-room commands should have deterministic CI output");50- assert(environment.JAZZ_APP_ID === "", "public application configuration must be cleared");51- assert(environment.JAZZ_ADMIN_SECRET === "", "server configuration must be cleared");52- assert(!("UNRELATED_SECRET" in environment), "unrelated ambient secrets must not be inherited");53-});54-55-Deno.test("retained evidence redacts allowlisted configuration values", () => {56- const output = redactText(57- "app id public-id server https://sync.invalid secret server-secret",58- ["public-id", "https://sync.invalid", "server-secret"],59- );60- assert(!output.includes("public-id"), "the app id must be redacted");61- assert(!output.includes("sync.invalid"), "the server URL must be redacted");62- assert(!output.includes("server-secret"), "server secrets must be redacted");63- assert(output.match(/\[redacted\]/g)?.length === 3, "every configured value must be replaced");64-});65-66-Deno.test("Node sentinel is first on PATH", async () => {67- if (Deno.build.os === "windows") return;68- const root = await workspaceTemp("node-sentinel");69- try {70- const environment = await installNodeSentinel({ PATH: "/allowed/bin" }, root);71- const sentinel = environment.PATH.split(":")[0];72- assert(73- resolve(sentinel).startsWith(resolve(root)),74- "the sentinel directory must be first on PATH",75- );76- assert(77- (await Deno.readTextFile(join(sentinel, "node"))).includes("hidden Node invocation"),78- "the sentinel must fail with an actionable message",79- );80- } finally {81- await removeWorkspaceTemp(root);82- }83-});84-85-Deno.test("clean project copy excludes secrets caches and the retired prototype", async () => {86- const source = await workspaceTemp("golden-source");87- const destination = await workspaceTemp("golden-destination");88- try {89- await Deno.mkdir(join(source, "apps/prototype"), { recursive: true });90- await Deno.mkdir(join(source, "apps/reference/src"), { recursive: true });91- await Deno.mkdir(join(source, "apps/reference/node_modules/package"), { recursive: true });92- await Deno.mkdir(join(source, "apps/reference/dist"), { recursive: true });93- await Deno.mkdir(join(source, ".nzip-site"), { recursive: true });94- await Promise.all([95- Deno.writeTextFile(join(source, ".env"), "BACKEND_SECRET=do-not-copy\n"),96- Deno.writeTextFile(join(source, ".env.local"), "JAZZ_APP_ID=do-not-copy\n"),97- Deno.writeTextFile(join(source, ".env.example"), "JAZZ_APP_ID=\n"),98- Deno.writeTextFile(join(source, "apps/prototype/retired.ts"), "retired\n"),99- Deno.writeTextFile(join(source, "apps/reference/src/app.ts"), "export const app = {};\n"),100- Deno.writeTextFile(join(source, "apps/reference/node_modules/package/index.js"), "cache\n"),101- Deno.writeTextFile(join(source, "apps/reference/dist/index.html"), "build\n"),102- Deno.writeTextFile(join(source, ".nzip-site/index.html"), "hosted\n"),103- ]);104-105- await materializeCleanProject(source, destination);106- assert(await exists(join(destination, ".env.example")), ".env.example should be copied");107- assert(108- await exists(join(destination, "apps/reference/src/app.ts")),109- "source should be copied",110- );111- assert(!await exists(join(destination, ".env")), ".env must be excluded");112- assert(!await exists(join(destination, ".env.local")), ".env.local must be excluded");113- assert(114- !await exists(join(destination, "apps/prototype")),115- "retired prototype must be excluded",116- );117- assert(118- !await exists(join(destination, "apps/reference/node_modules")),119- "node_modules must be excluded",120- );121- assert(!await exists(join(destination, "apps/reference/dist")), "dist must be excluded");122- assert(!await exists(join(destination, ".nzip-site")), "hosted artifacts must be excluded");123- } finally {124- await Promise.all([removeWorkspaceTemp(source), removeWorkspaceTemp(destination)]);125- }126-});127-128-Deno.test("clean project materialization copies only tracked files from a repository", async () => {129- const source = await workspaceTemp("tracked-source");130- const destination = await workspaceTemp("tracked-destination");131- try {132- await Deno.writeTextFile(join(source, "tracked.ts"), "export const tracked = true;\n");133- await Deno.writeTextFile(join(source, "untracked.ts"), "export const untracked = true;\n");134- for (const args of [["init", "-q"], ["add", "tracked.ts"]]) {135- const output = await new Deno.Command("git", { args, cwd: source }).output();136- assert(output.success, `git ${args[0]} fixture setup failed`);137- }138-139- await materializeCleanProject(source, destination);140- assert(await exists(join(destination, "tracked.ts")), "tracked source must be copied");141- assert(!await exists(join(destination, "untracked.ts")), "untracked source must be excluded");142- } finally {143- await Promise.all([144- removeWorkspaceTemp(source),145- removeWorkspaceTemp(destination),146- ]);147- }148-});149-150-Deno.test("author boundary permits schema declarations and reports UI plumbing", async () => {151- const root = await workspaceTemp("golden-boundary");152- try {153- await Deno.mkdir(join(root, "src/islands"), { recursive: true });154- await Promise.all([155- Deno.writeTextFile(156- join(root, "src/schema.ts"),157- 'import { co } from "jazz-tools";\nexport const schema = co;\n',158- ),159- Deno.writeTextFile(160- join(root, "src/permissions.ts"),161- 'import type { Group } from "jazz-tools";\nexport type Permission = Group;\n',162- ),163- Deno.writeTextFile(164- join(root, "src/islands/App.tsx"),165- 'import { createDb } from "jazz-tools";\nconst ready = navigator.storage.persisted();\n',166- ),167- ]);168-169- const violations = await scanAuthorBoundary(root, ["src"]);170- assert(violations.length === 3, `expected three violations, found ${violations.length}`);171- assert(172- violations.every((violation) => violation.path === "src/islands/App.tsx"),173- "schema and permissions should retain the explicit M1 exception",174- );175- assert(176- violations.some((violation) => violation.rule === "raw-jazz-runtime-import"),177- "raw imports in product UI should be reported",178- );179- assert(180- violations.some((violation) => violation.rule === "raw-jazz-client-api"),181- "raw client construction should be reported",182- );183- assert(184- violations.some((violation) => violation.rule === "browser-capability-branch"),185- "capability branching should be reported",186- );187- } finally {188- await removeWorkspaceTemp(root);189- }190-});191-192-Deno.test("process readiness resolves from output rather than a fixed sleep", async () => {193- const artifacts = await workspaceTemp("golden-process");194- try {195- const process = startReadyProcess({196- executable: "git",197- cwd: Deno.cwd(),198- args: ["--version"],199- environment: safeChildEnvironment({}),200- artifactRoot: artifacts,201- name: "ready-control",202- readyPattern: /(git version [^\n]+)/,203- timeoutMs: 5_000,204- });205- assert(206- (await process.ready).startsWith("git version "),207- "the emitted control line should satisfy readiness",208- );209- assert((await process.completed).success, "the readiness control should exit successfully");210- await process.stop();211- } finally {212- await removeWorkspaceTemp(artifacts);213- }214-});215-216-Deno.test("HTTP readiness waits until an early advertised URL accepts requests", async () => {217- const artifacts = await workspaceTemp("golden-http-ready");218- const port = reserveLocalPort();219- const url = `http://127.0.0.1:${port}/`;220- const started = performance.now();221- const process = startReadyProcess({222- executable: Deno.execPath(),223- cwd: Deno.cwd(),224- args: [225- "eval",226- `console.log("READY ${url}"); await new Promise((resolve) => setTimeout(resolve, 200)); Deno.serve({ hostname: "127.0.0.1", port: ${port} }, () => new Response("ready")); await new Promise(() => {});`,227- ],228- environment: safeChildEnvironment({}),229- artifactRoot: artifacts,230- name: "early-banner",231- readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,232- readyCheck: probeHttpReady,233- timeoutMs: 5_000,234- });235- try {236- assert(await process.ready === url, "the accepting URL should satisfy readiness");237- assert(238- performance.now() - started >= 150,239- "a printed banner must not resolve before the listener accepts requests",240- );241- } finally {242- await process.stop();243- await removeWorkspaceTemp(artifacts);244- }245-});246-247-Deno.test("post-banner exit rejects with retained diagnostics", async () => {248- const artifacts = await workspaceTemp("golden-http-exit");249- const port = reserveLocalPort();250- const process = startReadyProcess({251- executable: Deno.execPath(),252- cwd: Deno.cwd(),253- args: [254- "eval",255- `console.log("READY http://127.0.0.1:${port}/"); console.error("unique post-banner failure"); Deno.exit(42);`,256- ],257- environment: safeChildEnvironment({}),258- artifactRoot: artifacts,259- name: "post-banner-exit",260- readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,261- readyCheck: probeHttpReady,262- timeoutMs: 5_000,263- });264- try {265- let message = "";266- try {267- await process.ready;268- } catch (error) {269- message = error instanceof Error ? error.message : String(error);270- }271- assert(message.includes("exited with 42"), "readiness must report the process exit code");272- assert(message.includes("unique post-banner failure"), "readiness must include stderr tail");273- assert(message.includes(process.stderrPath), "readiness must point to retained stderr");274- await process.completed;275- } finally {276- await process.stop();277- await removeWorkspaceTemp(artifacts);278- }279-});280-281-Deno.test("stopping a ready wrapper terminates its serving grandchild", async () => {282- if (Deno.build.os === "windows") return;283- const artifacts = await workspaceTemp("golden-process-tree");284- const port = reserveLocalPort();285- const url = `http://127.0.0.1:${port}/`;286- const serverSource =287- `Deno.serve({ hostname: "127.0.0.1", port: ${port} }, () => new Response("child")); await new Promise(() => {});`;288- const wrapperSource = `const child = new Deno.Command(Deno.execPath(), { args: ["eval", ${289- JSON.stringify(serverSource)290- }], stdout: "inherit", stderr: "inherit" }).spawn(); console.log("READY ${url}"); await child.status;`;291- const process = startReadyProcess({292- executable: Deno.execPath(),293- cwd: Deno.cwd(),294- args: ["eval", wrapperSource],295- environment: safeChildEnvironment({}),296- artifactRoot: artifacts,297- name: "wrapper-tree",298- readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,299- readyCheck: probeHttpReady,300- timeoutMs: 5_000,301- });302- try {303- await process.ready;304- await process.stop();305- const rebound = Deno.listen({ hostname: "127.0.0.1", port });306- rebound.close();307- } finally {308- await process.stop().catch(() => undefined);309- await removeWorkspaceTemp(artifacts);310- }311-});312-313-Deno.test("captured commands retain output and duration", async () => {314- const artifacts = await workspaceTemp("golden-command");315- try {316- const record = await runCapturedCommand({317- executable: "git",318- cwd: Deno.cwd(),319- args: ["--version"],320- environment: safeChildEnvironment({}),321- artifactRoot: artifacts,322- name: "capture-control",323- });324- assert(record.exitCode === 0, "the control command should pass");325- assert(record.durationMs >= 0, "duration should be retained");326- assert(327- (await Deno.readTextFile(record.stdoutPath)).includes("git version"),328- "stdout should be retained",329- );330- } finally {331- await removeWorkspaceTemp(artifacts);332- }333-});334-335-Deno.test("deadline helper preserves a readiness result", async () => {336- assert(337- await withDeadline(Promise.resolve("ready"), 1_000, "control") === "ready",338- "a ready operation should win the deadline race",339- );340-});
D tools/golden_path_create.ts +0 -79
@@ -1,79 +0,0 @@
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-}
D tools/run_jazz2_spike.ts +0 -49
D tools/run_reference.ts +0 -176
@@ -1,176 +0,0 @@
1-import { environmentNames, validateEnvironment } from "./env_contract.ts";2-import { loadEnvironment } from "./load_env.ts";3-4-const INTERNAL_ENV = "apps/reference/.env";5-const LOCAL_APP_ID = "00000000-0000-0000-0000-00000000f153";6-7-async function ensureStableLocalAppId() {8- let content = "";9- try {10- content = await Deno.readTextFile(INTERNAL_ENV);11- } catch (error) {12- if (!(error instanceof Deno.errors.NotFound)) throw error;13- }14- if (/^VITE_JAZZ_APP_ID=/m.test(content)) return;15- const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";16- await Deno.writeTextFile(17- INTERNAL_ENV,18- `${content}${separator}VITE_JAZZ_APP_ID=${LOCAL_APP_ID}\n`,19- );20-}21-22-async function forward(stream: ReadableStream<Uint8Array>, target: WritableStream<Uint8Array>) {23- await stream.pipeTo(target, { preventClose: true });24-}25-26-async function run(args: string[], env: Record<string, string>): Promise<number> {27- const child = new Deno.Command(Deno.execPath(), {28- args,29- env,30- stdin: "inherit",31- stdout: "piped",32- stderr: "piped",33- }).spawn();34- const forwardSignal = (signal: Deno.Signal) => {35- try {36- child.kill(signal);37- } catch (error) {38- if (!(error instanceof Deno.errors.NotFound)) throw error;39- }40- };41- const onInterrupt = () => forwardSignal("SIGINT");42- const onTerminate = () => forwardSignal("SIGTERM");43- Deno.addSignalListener("SIGINT", onInterrupt);44- Deno.addSignalListener("SIGTERM", onTerminate);45- const output = forward(child.stdout, Deno.stdout.writable);46- const errors = forward(child.stderr, Deno.stderr.writable);47- try {48- const [status] = await Promise.all([child.status, output, errors]);49- return status.code;50- } finally {51- Deno.removeSignalListener("SIGINT", onInterrupt);52- Deno.removeSignalListener("SIGTERM", onTerminate);53- }54-}55-56-async function routeCount(): Promise<number> {57- let count = 0;58- async function visit(path: string) {59- for await (const entry of Deno.readDir(path)) {60- const child = `${path}/${entry.name}`;61- if (entry.isDirectory) await visit(child);62- else if (entry.isFile && entry.name.endsWith(".html")) count += 1;63- }64- }65- try {66- await visit("apps/reference/dist");67- } catch (error) {68- if (!(error instanceof Deno.errors.NotFound)) throw error;69- }70- return count;71-}72-73-async function buildFiles(root: string): Promise<string[]> {74- const files: string[] = [];75- async function visit(path: string) {76- for await (const entry of Deno.readDir(path)) {77- const child = `${path}/${entry.name}`;78- if (entry.isDirectory) await visit(child);79- else if (entry.isFile) files.push(`./${child.slice(root.length + 1)}`);80- }81- }82- await visit(root);83- return files.sort();84-}85-86-const command = Deno.args[0];87-if (command !== "dev" && command !== "build") {88- console.error("usage: run_reference.ts <dev|build>");89- Deno.exit(2);90-}91-92-const environment = await loadEnvironment();93-const validation = validateEnvironment(environment);94-if (!validation.ok) {95- console.error("lofi environment mode: invalid");96- for (const error of validation.errors) console.error(`error: ${error}`);97- Deno.exit(1);98-}99-for (const warning of validation.warnings) console.warn(`warning: ${warning}`);100-await ensureStableLocalAppId();101-102-const validatedEnvironment = Object.fromEntries(103- environmentNames104- .map((name) => [name, environment[name]?.trim() ?? ""] as const)105- .filter(([, value]) => value.length > 0),106-);107-const childEnvironment = { ...validatedEnvironment };108-if (command === "dev") childEnvironment.ASTRO_DEV_BACKGROUND = "1";109-if (command === "build") {110- childEnvironment.LOFI_SKIP_JAZZ_MANAGED = "1";111- childEnvironment.JAZZ_ADMIN_SECRET = "";112- childEnvironment.BACKEND_SECRET = "";113-}114-115-const forwardedArgs = Deno.args.slice(1).filter((argument, index) => {116- return !(index === 0 && argument === "--");117-});118-119-if (command === "dev") {120- console.log("lofi dev");121- console.log("Storage: persistent/OPFS requested; browser diagnosis in UI");122- console.log("Identity: device-local key; passkey backup blocked by alpha security review");123- console.log(124- `Sync: ${125- validation.mode === "cloud-configured" ? "managed configured" : "local development server"126- }`,127- );128- console.log("PWA: development service worker disabled");129-}130-131-const exitCode = await run(132- [133- "run",134- "-A",135- "npm:astro@7.0.9",136- command,137- "--root",138- "apps/reference",139- ...forwardedArgs,140- ],141- childEnvironment,142-);143-if (exitCode !== 0) Deno.exit(exitCode);144-145-if (command === "build") {146- const revision = new TextDecoder().decode(147- (await new Deno.Command("git", {148- args: ["rev-parse", "--short", "HEAD"],149- stdout: "piped",150- }).output()).stdout,151- ).trim();152- await Deno.writeTextFile(153- "apps/reference/dist/lofi-build.json",154- `${JSON.stringify({ revision, builtAt: new Date().toISOString() })}\n`,155- );156- const serviceWorkerPath = "apps/reference/dist/sw.js";157- const serviceWorker = await Deno.readTextFile(serviceWorkerPath);158- await Deno.writeTextFile(159- serviceWorkerPath,160- serviceWorker.replace("__LOFI_BUILD_REVISION__", revision),161- );162- const precache = (await buildFiles("apps/reference/dist"))163- .filter((path) => path !== "./lofi-precache.json")164- .map((path) => path === "./index.html" ? "./" : path);165- precache.push("./lofi-precache.json");166- await Deno.writeTextFile(167- "apps/reference/dist/lofi-precache.json",168- `${JSON.stringify(precache.sort())}\n`,169- );170- const scanCode = await run(171- ["task", "check:secrets"],172- validatedEnvironment,173- );174- if (scanCode !== 0) Deno.exit(scanCode);175- console.log(`lofi build: apps/reference/dist (${await routeCount()} routes, ${revision})`);176-}