clone

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

fix(testing): make retained traces value-free

Dami committed f4fb50a
commit
f4fb50ac2a88bb7725da906b2e15e5e0b3b7193e
parent
d51a1104a8af6d04bbef6bb5cbd1c9bb58ca9f41
author
Dami <dami.etoile@gmail.com>
3 files changed, +179 -35
Mpackage/testing/safety_test.ts6+-
Mpackage/testing/trace.ts7+-
Mtools/golden_path.ts201+++++++++++++++++++++++++-----
M package/testing/safety_test.ts +5 -1
M package/testing/trace.ts +6 -1
M tools/golden_path.ts +168 -33
@@ -5,7 +5,12 @@ import {
55 runConcurrentOfflineConvergence,66 } from "@nzip/lofi/testing";77 import { type Browser, type BrowserContext, chromium, type Page } from "playwright";8-import { environmentNames, validateEnvironment } from "./env_contract.ts";8+import {9+ clientEnvironmentNames,10+ environmentNames,11+ serverEnvironmentNames,12+ validateEnvironment,13+} from "./env_contract.ts";914 import {1015 artifactPaths,1116 type CapturedProcess,
@@ -26,6 +31,8 @@ import {
2631 } from "./golden_path_core.ts";2732 import { loadEnvironment } from "./load_env.ts";2833 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";2936 import { assert } from "./assert.ts";3037 3138 export type GoldenPathCommands = {
@@ -193,12 +200,10 @@ async function restartClientThroughInspector(
193200 retainedItem: string,194201 timeoutMs: number,195202 ): Promise<void> {203+ const identity = await armIdentityComparison(page);196204 const before = await page.evaluate(() => ({197205 timeOrigin: performance.timeOrigin,198206 url: location.href,199- identity: JSON.stringify(200- Object.entries(localStorage).sort(([left], [right]) => left.localeCompare(right)),201- ),202207 }));203208 const navigated = page.waitForEvent("domcontentloaded", { timeout: timeoutMs });204209 await page.getByRole("button", { name: "Restart client" }).click();
@@ -206,13 +211,13 @@ async function restartClientThroughInspector(
206211 const after = await page.evaluate(() => ({207212 timeOrigin: performance.timeOrigin,208213 url: location.href,209- identity: JSON.stringify(210- Object.entries(localStorage).sort(([left], [right]) => left.localeCompare(right)),211- ),212214 }));213215 assert(after.timeOrigin !== before.timeOrigin, "inspector restart did not replace the document");214216 assert(after.url === before.url, "inspector restart changed the application URL");215- assert(after.identity === before.identity, "inspector restart changed the device identity");217+ assert(218+ await identityComparisonMatches(page, identity.token),219+ "inspector restart changed the device identity",220+ );216221 await waitForItem(page, islandLabels, retainedItem);217222 for (const label of islandLabels) {218223 const island = page.locator("[data-island]").filter({ hasText: label });
@@ -224,6 +229,39 @@ async function restartClientThroughInspector(
224229 await assertRuntimeCardinality(page, islandLabels);225230 }226231 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+227265 async function exerciseHmr(228266 page: Page,229267 projectRoot: string,
@@ -285,27 +323,17 @@ async function exerciseReplicaClear(
285323 await addItem(page, islandLabels[0], replicaClearBody);286324 await waitForItem(page, islandLabels, replicaClearBody);287325 await waitForLocalDurability(page);288- const identityBefore = await page.evaluate(() =>289- JSON.stringify(290- Object.entries(globalThis.localStorage).sort(([left], [right]) =>291- left.localeCompare(right)292- ),293- )294- );295- assert(identityBefore !== "[]", "replica-clear fixture did not create an identity state");326+ const identity = await armIdentityComparison(page);327+ assert(identity.present, "replica-clear fixture did not create an identity state");296328 const navigated = page.waitForEvent("domcontentloaded", { timeout: timeoutMs });297329 page.once("dialog", (dialog) => dialog.accept());298330 await page.getByRole("button", { name: "Clear local replica" }).click();299331 await navigated;300332 await waitForItemAbsent(page, islandLabels, replicaClearBody);301- const identityAfter = await page.evaluate(() =>302- JSON.stringify(303- Object.entries(globalThis.localStorage).sort(([left], [right]) =>304- left.localeCompare(right)305- ),306- )333+ assert(334+ await identityComparisonMatches(page, identity.token),335+ "replica clear changed the device-local identity",307336 );308- assert(identityBefore === identityAfter, "replica clear changed the device-local identity");309337 } finally {310338 await context.close();311339 }
@@ -347,7 +375,7 @@ async function observeReconnectSettlement(
347375 const failure = page.getByRole("status").filter({ hasText: checklistUi.writeFailed }).first();348376 await global.or(failure).waitFor({349377 state: "visible",350- timeout: Math.min(timeoutMs, 10_000),378+ timeout: timeoutMs,351379 });352380 if (await failure.isVisible()) {353381 throw new Error((await failure.textContent()) ?? "Write failed");
@@ -355,7 +383,7 @@ async function observeReconnectSettlement(
355383 return {356384 name: "development reconnect settlement",357385 status: "passed",358- detail: "The configured development server exposed global settlement after network return.",386+ detail: "The configured server exposed global settlement after reconnection.",359387 };360388 } catch {361389 throw new Error(
@@ -381,7 +409,10 @@ async function journeyEnvironment(
381409 );382410 }383411 const allowlisted = { ...isolated };384- for (const name of environmentNames) allowlisted[name] = configured[name]?.trim() ?? "";412+ for (const name of clientEnvironmentNames) {413+ allowlisted[name] = configured[name]?.trim() ?? "";414+ }415+ for (const name of serverEnvironmentNames) allowlisted[name] = "";385416 return await installNodeSentinel(allowlisted, artifactRoot);386417 }387418
@@ -595,6 +626,49 @@ async function screenshotFailure(page: Page | null, path: string): Promise<void>
595626 }596627 }597628 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+598672 function commandFailure(record: CommandRecord): Error {599673 return new Error(600674 `${record.name} failed with exit ${record.exitCode}; rerun ${Deno.execPath()} ${
@@ -645,7 +719,14 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
645719 const commands = { ...defaultCommands, ...options.commands };646720 const islandLabels = options.islandLabels ?? ["North island", "South island"];647721 const environment = await journeyEnvironment(sourceRoot, environmentMode, artifacts.root);648- const redactValues = environmentNames.map((name) => environment[name]).filter(Boolean);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+ ];649730 const commit = await sourceRevision(revisionRoot, environment);650731 const commandRecords: CommandRecord[] = [...options.initialCommands ?? []];651732 const assertions: JourneyAssertion[] = [];
@@ -716,6 +797,19 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
716797 status: "passed",717798 detail: "No forbidden runtime, worker, transport, Workbox, or capability plumbing found.",718799 });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+ }719813 720814 if (commands.doctor) {721815 const doctor = await runCapturedCommand({
@@ -746,7 +840,7 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
746840 browserVersion = browser.version();747841 report.runtime.browser = browserVersion;748842 context = await browser.newContext();749- await context.tracing.start({ screenshots: true, snapshots: true, sources: true });843+ await context.tracing.start({ screenshots: false, snapshots: false, sources: false });750844 page = await context.newPage();751845 752846 const port = reserveLocalPort();
@@ -868,21 +962,36 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
868962 869963 await addItem(page, islandLabels[0], disposableBody);870964 await waitForItem(page, islandLabels, disposableBody);965+ if (environmentMode === "cloud-from-root") {966+ await observeReconnectSettlement(page, timeoutMs, true);967+ }871968 await deleteItem(page, disposableBody);872969 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+ }873979 assertions.push({874980 name: "accessible CRUD",875981 status: "passed",876982 detail: "Create, update, complete, and delete worked through named author-facing controls.",877983 });878984 879- if (environmentMode === "cloud-from-root") await setReferenceConnection(page, false);880- await context.setOffline(true);985+ if (environmentMode === "cloud-from-root") {986+ await setReferenceConnection(page, false);987+ } else {988+ await context.setOffline(true);989+ }881990 await addItem(page, islandLabels[1], offlineDevelopmentBody);882991 await waitForItem(page, islandLabels, offlineDevelopmentBody);883992 await waitForLocalDurability(page);884- await context.setOffline(false);885993 if (environmentMode === "cloud-from-root") await setReferenceConnection(page, true);994+ else await context.setOffline(false);886995 assertions.push(887996 await observeReconnectSettlement(page, timeoutMs, environmentMode === "cloud-from-root"),888997 );
@@ -891,7 +1000,9 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
8911000 assertions.push({8921001 name: "development offline write",8931002 status: "passed",894- detail: "A loaded page accepted a local write offline and retained it after network return.",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.",8951006 });8961007 8971008 if (environmentMode === "isolated-local") {
@@ -1050,7 +1161,31 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
10501161 } finally {10511162 if (context) {10521163 await context.setOffline(false).catch(() => undefined);1053- await context.tracing.stop({ path: artifacts.trace }).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+ }10541189 }10551190 await preview?.stop().catch(() => undefined);10561191 await dev?.stop().catch(() => undefined);