clone

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

test(golden): prove cloud two-client convergence

Dami committed d51a110
commit
d51a1104a8af6d04bbef6bb5cbd1c9bb58ca9f41
parent
d215191b960f75c9f9414afff5834d00a218ab2e
author
Dami <dami.etoile@gmail.com>
2 files changed, +155 -8
Mdocs/m2-abstraction-leaks.md21+++--
Mtools/golden_path.ts142++++++++++++++++++++++++++++++
M docs/m2-abstraction-leaks.md +13 -8
@@ -6,14 +6,19 @@ Last reviewed: 2026-07-15
66 This ledger keeps the Layer 1 reference application honest. A listed leak is not a supported lofi77 API; it is a concrete input to the layer that owns removing or productizing it.88 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-| Runtime cardinality and lifecycle evidence use a development-only global probe. | Product UI must stay clean while the browser journey still needs deterministic evidence. | #10 | The inspector exposes the same truthful signals through a supported development surface and is absent from production. |15-| The checkout golden path owns process/browser orchestration at repository scope. | Layer 1 proves the journey before defining a reusable testing package. | #12 | Offline and multi-client helpers are callable from generated-project tests with readiness-based APIs. |16-| Cloud/global settlement is observable per write, but live socket state is not. | Jazz alpha.53 exposes mutation durability but no supported connection-state signal. | #10, #12 | Inspector/tests distinguish configured mode, local durability, global durability, and unavailable live detail. |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. |1722 1823 Deleting an entry requires retained evidence in the owning issue or a decision record accepting the1924 behavior as an intentional public boundary.
M tools/golden_path.ts +142 -0
@@ -1,4 +1,9 @@
11 import { join, resolve } from "node:path";2+import {3+ type BrowserTestClient,4+ createTwoClientFixture,5+ runConcurrentOfflineConvergence,6+} from "@nzip/lofi/testing";27 import { type Browser, type BrowserContext, chromium, type Page } from "playwright";38 import { environmentNames, validateEnvironment } from "./env_contract.ts";49 import {
@@ -77,6 +82,9 @@ const offlineDevelopmentBody = "Golden path development offline item";
7782 const offlineProductionBody = "Golden path production offline item";7883 const offlineProductionSyncedBody = "Golden path production offline item synced";7984 const replicaClearBody = "Golden path replica-clear disposable item";85+const convergenceTextSeedBody = "Golden path two-client text item";86+const convergenceEditedBody = "Golden path two-client text item edited";87+const convergenceCompletionBody = "Golden path two-client completion item";8088 8189 function taskArgs(task: string, extra: string[] = []): string[] {8290 return ["task", task, ...(extra.length > 0 ? ["--", ...extra] : [])];
@@ -427,6 +435,11 @@ async function assertCompleted(page: Page, body: string): Promise<void> {
427435 const name = checklistUi.complete(body);428436 const checkboxes = page.getByRole("checkbox", { name });429437 await checkboxes.first().waitFor({ state: "visible" });438+ await page.waitForFunction((accessibleName) => {439+ const matches = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]440+ .filter((element) => element.getAttribute("aria-label") === accessibleName);441+ return matches.length === 2 && matches.every((element) => element.checked);442+ }, name);430443 assert(await checkboxes.count() === 2, `expected two completion controls for ${body}`);431444 assert(432445 await checkboxes.evaluateAll((elements) =>
@@ -461,6 +474,118 @@ async function waitForProductionPwa(page: Page, timeoutMs: number): Promise<void
461474 });462475 }463476 477+async function waitForChecklistReady(page: Page, timeoutMs: number): Promise<void> {478+ await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({479+ state: "visible",480+ timeout: timeoutMs,481+ });482+ await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({483+ state: "visible",484+ timeout: timeoutMs,485+ });486+}487+488+async function valueFreeClientState(client: BrowserTestClient) {489+ const renderedRows = await client.page.locator("[data-task-id]").count();490+ const completedControls = await client.page.locator('input[type="checkbox"]:checked').count();491+ return { offline: client.offline, renderedRows, completedControls };492+}493+494+async function exerciseTwoClientCloudConvergence(495+ browser: Browser,496+ url: string,497+ islandLabels: readonly string[],498+ timeoutMs: number,499+ artifactRoot: string,500+ redactValues: readonly string[],501+): Promise<void> {502+ const fixture = await createTwoClientFixture({503+ baseURL: url,504+ browser,505+ identity: {506+ mode: "shared",507+ async preparePrimary(client) {508+ await waitForProductionPwa(client.page, timeoutMs);509+ await waitForChecklistReady(client.page, timeoutMs);510+ },511+ },512+ artifacts: {513+ directory: join(artifactRoot, "two-client-convergence"),514+ secretValues: redactValues,515+ },516+ });517+ try {518+ await Promise.all(fixture.clients.map(async (client) => {519+ await waitForProductionPwa(client.page, timeoutMs);520+ await waitForChecklistReady(client.page, timeoutMs);521+ }));522+ await addItem(fixture.first.page, islandLabels[0], convergenceTextSeedBody);523+ await waitForItem(fixture.first.page, islandLabels, convergenceTextSeedBody);524+ await addItem(fixture.first.page, islandLabels[0], convergenceCompletionBody);525+ await waitForItem(fixture.first.page, islandLabels, convergenceCompletionBody);526+ await observeReconnectSettlement(fixture.first.page, timeoutMs, true);527+ await waitForItem(fixture.second.page, islandLabels, convergenceTextSeedBody);528+ await waitForItem(fixture.second.page, islandLabels, convergenceCompletionBody);529+530+ type ConvergenceEdit = { kind: "text" } | { kind: "completion" };531+ await runConcurrentOfflineConvergence<ConvergenceEdit, BrowserTestClient>(fixture, {532+ edits: [{ kind: "text" }, { kind: "completion" }],533+ async ready(client) {534+ await waitForProductionPwa(client.page, timeoutMs);535+ await waitForItem(client.page, islandLabels, convergenceTextSeedBody);536+ await waitForItem(client.page, islandLabels, convergenceCompletionBody);537+ },538+ async apply(client, edit) {539+ if (edit.kind === "text") {540+ await updateItem(client.page, convergenceTextSeedBody, convergenceEditedBody);541+ } else {542+ await completeItem(client.page, convergenceCompletionBody);543+ }544+ },545+ async locallyApplied(client, edit) {546+ if (edit.kind === "text") {547+ await waitForItem(client.page, islandLabels, convergenceEditedBody);548+ } else {549+ await assertCompleted(client.page, convergenceCompletionBody);550+ }551+ await waitForLocalDurability(client.page);552+ },553+ async whilePending(current) {554+ await current.first.reloadPage();555+ await waitForProductionPwa(current.first.page, timeoutMs);556+ await waitForItem(current.first.page, islandLabels, convergenceEditedBody);557+558+ await current.second.restartPage();559+ await waitForProductionPwa(current.second.page, timeoutMs);560+ await waitForItem(current.second.page, islandLabels, convergenceCompletionBody);561+ await assertCompleted(current.second.page, convergenceCompletionBody);562+ },563+ async converged(current) {564+ for (const client of current.clients) {565+ await waitForItem(client.page, islandLabels, convergenceEditedBody);566+ await assertCompleted(client.page, convergenceCompletionBody);567+ }568+ },569+ snapshot: valueFreeClientState,570+ failureLabel: "cloud-two-client-convergence",571+ });572+573+ for (const body of [convergenceEditedBody, convergenceCompletionBody]) {574+ await deleteItem(fixture.first.page, body);575+ await waitForItemAbsent(fixture.first.page, islandLabels, body);576+ await observeReconnectSettlement(fixture.first.page, timeoutMs, true);577+ await waitForItemAbsent(fixture.second.page, islandLabels, body);578+ }579+ } catch (error) {580+ await fixture.captureFailure("cloud-two-client-cleanup", valueFreeClientState).catch(() => {581+ // The original convergence or cleanup failure remains authoritative.582+ });583+ throw error;584+ } finally {585+ await fixture.close();586+ }587+}588+464589 async function screenshotFailure(page: Page | null, path: string): Promise<void> {465590 if (!page || page.isClosed()) return;466591 try {
@@ -882,6 +1007,23 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
8821007 detail: "The service-worker shell and OPFS data survived cold offline reload and editing.",8831008 });8841009 1010+ if (environmentMode === "cloud-from-root") {1011+ await exerciseTwoClientCloudConvergence(1012+ browser,1013+ previewUrl,1014+ islandLabels,1015+ timeoutMs,1016+ artifacts.root,1017+ redactValues,1018+ );1019+ assertions.push({1020+ name: "cloud two-client convergence",1021+ status: "passed",1022+ detail:1023+ "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.",1024+ });1025+ }1026+8851027 if (environmentMode === "cloud-from-root") {8861028 for (const body of [updatedBody, offlineDevelopmentBody, offlineProductionSyncedBody]) {8871029 await deleteItem(page, body);