clone

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

feat(pwa): add related app discovery recipe

Dami committed b34e354
commit
b34e354dc61b63e93dd3aab6bd22ac1bbcfaae47
parent
726d6d21435222b172f14c1b33bf757c8dd9c81e
author
Dami <dami.etoile@gmail.com>
17 files changed, +488 -20
MREADME.md4+-
Mapps/reference/deno.json1+
Mdeno.json1+
Mdocs/getting-started.md25+++---
Mdocs/recipes/README.md2+
Adocs/recipes/related-app-discovery.md54+++++++++++++
Mpackage/astro/mod.ts14+++
Mpackage/astro/mod_test.ts2+
Mpackage/create_core.ts7+-
Mpackage/create_core_test.ts9++-
Apackage/recipes/related-app-discovery.ts125++++++++++++++++++++++++++++++
Apackage/recipes/related-app-discovery_test.ts62+++++++++++++++
Mpackage/testdata/starter.snapshot.json4+-
Mpackage/tooling/pwa-validation.ts77++++++++++++++++++
Mpackage/tooling/pwa-validation_test.ts31+++++++
Mtools/doc_lint.ts1+
Mtools/golden.ts89+++++++++++++++++++++-
M README.md +2 -2
M apps/reference/deno.json +1 -0
M deno.json +1 -0
M docs/getting-started.md +13 -12
@@ -62,18 +62,19 @@ allowing framework fixes to arrive through a package upgrade.
6262 6363 The final package map is intentionally small:6464 65-| Import | Supported use |66-| ------------------------------------- | --------------------------------------------------- |67-| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68-| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69-| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70-| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71-| `@nzip/lofi/recipes/file-handler` | Optional validated installed-app file import drafts |72-| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |73-| `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links |74-| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |75-| `@nzip/lofi/testing` | Local-first Playwright helpers |76-| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |65+| Import | Supported use |66+| ------------------------------------------ | --------------------------------------------------- |67+| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68+| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69+| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70+| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71+| `@nzip/lofi/recipes/file-handler` | Optional validated installed-app file import drafts |72+| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |73+| `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links |74+| `@nzip/lofi/recipes/related-app-discovery` | Optional presentation-only companion discovery |75+| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |76+| `@nzip/lofi/testing` | Local-first Playwright helpers |77+| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |7778 7879 All of these imports resolve through the single version pinned in `deno.json`. Upgrade that pin to7980 receive framework fixes; do not copy package files into `src/` or import unpublished internal paths.
M docs/recipes/README.md +2 -0
A docs/recipes/related-app-discovery.md +54 -0
@@ -0,0 +1,54 @@
1+# Related-application discovery2+3+Use this recipe only when the product already owns a native or installed-web companion and has4+completed that platform's bidirectional verification. The5+[`getInstalledRelatedApps()` API](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps)6+is experimental, limited to top-level secure contexts in supporting browsers, and intended here only7+to avoid redundant presentation such as a companion-app onboarding banner.8+9+## Manifest and verification10+11+The starter omits both members. Add the verified listing while leaving native preference false:12+13+```json14+{15+ "prefer_related_applications": false,16+ "related_applications": [17+ {18+ "platform": "play",19+ "id": "com.example.companion",20+ "url": "https://play.google.com/store/apps/details?id=com.example.companion"21+ }22+ ]23+}24+```25+26+Do not invent identifiers. Android requires Digital Asset Links; Windows, cross-scope PWAs, and27+other platforms have their own verification mechanisms. Manifest declaration alone is not proof of28+ownership. Keep `prefer_related_applications` false or omitted so Chromium retains PWA29+installability.30+31+## Presentation-only discovery32+33+```ts34+import { discoverRelatedApplications } from "@nzip/lofi/recipes/related-app-discovery";35+36+const discovery = await discoverRelatedApplications({37+ allow: [{38+ platform: "play",39+ id: "com.example.companion",40+ url: "https://play.google.com/store/apps/details?id=com.example.companion",41+ }],42+});43+44+const showCompanionOnboarding = discovery.status !== "installed";45+```46+47+Only exact allow-list matches are returned. Browser-supplied versions, unknown apps, and extra48+fields are discarded. Unsupported, empty, malformed, embedded-context, and rejected calls all retain49+the normal PWA experience. Never use discovery for authentication, authorization, account linking,50+feature entitlements, or proof that the current user controls the companion app.51+52+Test unsupported and empty results, exact and mismatched platform IDs/URLs, API rejection, top-level53+versus embedded contexts, privacy-safe UI, PWA installability, platform verification updates, and54+removing the manifest listing.
M package/astro/mod.ts +14 -0
@@ -65,6 +65,7 @@ const recipeFiles = [
6565 "file-handler.ts",6666 "launch-handler.ts",6767 "protocol-handler.ts",68+ "related-app-discovery.ts",6869 "web-share.ts",6970 ] as const;7071
@@ -97,6 +98,13 @@ function renderConfig(projectRoot: string): string {
9798 "recipes",9899 "protocol-handler.ts",99100 );101+ const relatedAppDiscoveryRecipe = join(102+ projectRoot,103+ ".lofi",104+ "package",105+ "recipes",106+ "related-app-discovery.ts",107+ );100108 const webShareRecipe = join(projectRoot, ".lofi", "package", "recipes", "web-share.ts");101109 return `import preact from "@astrojs/preact";102110 import { defineConfig } from "astro/config";
@@ -145,6 +153,9 @@ export default defineConfig({
145153 } },146154 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/protocol-handler$/, replacement: ${147155 JSON.stringify(protocolHandlerRecipe)156+ } },157+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/related-app-discovery$/, replacement: ${158+ JSON.stringify(relatedAppDiscoveryRecipe)148159 } },149160 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/web-share$/, replacement: ${150161 JSON.stringify(webShareRecipe)
@@ -164,6 +175,9 @@ export default defineConfig({
164175 } },165176 { find: "@nzip/lofi/recipes/protocol-handler", replacement: ${166177 JSON.stringify(protocolHandlerRecipe)178+ } },179+ { find: "@nzip/lofi/recipes/related-app-discovery", replacement: ${180+ JSON.stringify(relatedAppDiscoveryRecipe)167181 } },168182 { find: "@nzip/lofi/recipes/file-handler", replacement: ${169183 JSON.stringify(fileHandlerRecipe)
M package/astro/mod_test.ts +2 -0
M package/create_core.ts +6 -1
M package/create_core_test.ts +7 -2
A package/recipes/related-app-discovery.ts +125 -0
@@ -0,0 +1,125 @@
1+/**2+ * Opt-in presentation-only discovery for verified related applications.3+ *4+ * @module5+ */6+7+/** A product-owned related application declaration. */8+export type VerifiedRelatedApplication = {9+ readonly platform: string;10+ readonly id?: string;11+ readonly url?: string;12+};13+14+/** Minimal browser discovery client used by the recipe. */15+export type RelatedApplicationClient = {16+ getInstalledRelatedApps(): Promise<readonly unknown[]>;17+};18+19+/** Presentation-only discovery result. */20+export type RelatedApplicationDiscovery =21+ | { status: "unsupported" | "none" | "failed"; installed: readonly [] }22+ | { status: "installed"; installed: readonly VerifiedRelatedApplication[] };23+24+/** Options for matching browser results against product-owned declarations. */25+export type DiscoverRelatedApplicationsOptions = {26+ /** Declarations copied from a verified `related_applications` manifest entry. */27+ allow: readonly VerifiedRelatedApplication[];28+ /** Override the browser API for tests. */29+ client?: RelatedApplicationClient;30+};31+32+const platforms = new Set([33+ "amazon",34+ "chrome_web_store",35+ "chromeos_play",36+ "f-droid",37+ "play",38+ "webapp",39+ "windows",40+]);41+42+function nonEmptyBounded(value: unknown): value is string {43+ return typeof value === "string" && value.trim() === value && value.length > 0 &&44+ value.length <= 512 && ![...value].some((character) => {45+ const code = character.codePointAt(0)!;46+ return code <= 0x1f || code === 0x7f;47+ });48+}49+50+function normalizeDeclaration(value: VerifiedRelatedApplication): VerifiedRelatedApplication {51+ if (!platforms.has(value.platform) || !nonEmptyBounded(value.id) && !nonEmptyBounded(value.url)) {52+ throw new TypeError("related application needs a supported platform and an id or HTTPS URL");53+ }54+ let url: string | undefined;55+ if (value.url !== undefined) {56+ if (!nonEmptyBounded(value.url)) throw new TypeError("related application URL is invalid");57+ const parsed = new URL(value.url);58+ if (parsed.protocol !== "https:" || parsed.username || parsed.password) {59+ throw new TypeError("related application URL must be credential-free HTTPS");60+ }61+ url = parsed.href;62+ }63+ if (value.id !== undefined && !nonEmptyBounded(value.id)) {64+ throw new TypeError("related application ID is invalid");65+ }66+ return { platform: value.platform, id: value.id, url };67+}68+69+function browserClient(): RelatedApplicationClient | undefined {70+ if (typeof navigator === "undefined") return undefined;71+ const candidate = navigator as Navigator & {72+ getInstalledRelatedApps?: RelatedApplicationClient["getInstalledRelatedApps"];73+ };74+ return typeof candidate.getInstalledRelatedApps === "function"75+ ? { getInstalledRelatedApps: candidate.getInstalledRelatedApps.bind(candidate) }76+ : undefined;77+}78+79+function isObject(value: unknown): value is Record<string, unknown> {80+ return value !== null && typeof value === "object" && !Array.isArray(value);81+}82+83+/**84+ * Discover installed companions and return only exact allow-list matches.85+ *86+ * Use the result to adjust presentation such as redundant onboarding. It is87+ * intentionally unsuitable for authentication or authorization: unknown fields88+ * and versions are discarded, and errors degrade to the normal PWA experience.89+ */90+export async function discoverRelatedApplications(91+ options: DiscoverRelatedApplicationsOptions,92+): Promise<RelatedApplicationDiscovery> {93+ const allow = options.allow.map(normalizeDeclaration);94+ const keys = allow.map((entry) => JSON.stringify(entry));95+ if (new Set(keys).size !== keys.length) {96+ throw new TypeError("related application declarations must not repeat");97+ }98+ const client = options.client ?? browserClient();99+ if (!client) return { status: "unsupported", installed: [] };100+ let values: readonly unknown[];101+ try {102+ values = await client.getInstalledRelatedApps();103+ } catch {104+ return { status: "failed", installed: [] };105+ }106+ if (!Array.isArray(values)) return { status: "failed", installed: [] };107+ const installed = allow.filter((declaration) =>108+ values.some((raw) => {109+ if (!isObject(raw) || raw.platform !== declaration.platform) return false;110+ if (declaration.id !== undefined && raw.id !== declaration.id) return false;111+ if (declaration.url !== undefined) {112+ if (typeof raw.url !== "string") return false;113+ try {114+ if (new URL(raw.url).href !== declaration.url) return false;115+ } catch {116+ return false;117+ }118+ }119+ return true;120+ })121+ );122+ return installed.length > 0123+ ? { status: "installed", installed }124+ : { status: "none", installed: [] };125+}
A package/recipes/related-app-discovery_test.ts +62 -0
@@ -0,0 +1,62 @@
1+import { discoverRelatedApplications } from "./related-app-discovery.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+const allow = [{8+ platform: "play",9+ id: "com.example.companion",10+ url: "https://play.google.com/store/apps/details?id=com.example.companion",11+}] as const;12+13+Deno.test("related app discovery returns exact product-owned matches for presentation", async () => {14+ const result = await discoverRelatedApplications({15+ allow,16+ client: {17+ getInstalledRelatedApps: () =>18+ Promise.resolve([{19+ platform: "play",20+ id: "com.example.companion",21+ url: "https://play.google.com/store/apps/details?id=com.example.companion",22+ version: "private-version",23+ }, { platform: "play", id: "attacker.app" }]),24+ },25+ });26+ assert(result.status === "installed", `match failed: ${JSON.stringify(result)}`);27+ assert(result.installed.length === 1, "unknown app was exposed");28+ assert(!("version" in result.installed[0]), "browser version escaped the presentation boundary");29+});30+31+Deno.test("related app discovery makes unsupported, empty, and failure normal states", async () => {32+ const unsupported = await discoverRelatedApplications({ allow });33+ assert(unsupported.status === "unsupported", "missing API was not unsupported");34+ const none = await discoverRelatedApplications({35+ allow,36+ client: { getInstalledRelatedApps: () => Promise.resolve([]) },37+ });38+ assert(none.status === "none", "empty result was not normal");39+ const failed = await discoverRelatedApplications({40+ allow,41+ client: { getInstalledRelatedApps: () => Promise.reject(new Error("private")) },42+ });43+ assert(failed.status === "failed", "API failure did not degrade safely");44+});45+46+Deno.test("related app discovery rejects invented declarations", async () => {47+ for (48+ const declaration of [49+ { platform: "invented", id: "app" },50+ { platform: "play" },51+ { platform: "play", id: "app", url: "http://store.example/app" },52+ ]53+ ) {54+ let rejected = false;55+ try {56+ await discoverRelatedApplications({ allow: [declaration] });57+ } catch (error) {58+ rejected = error instanceof TypeError;59+ }60+ assert(rejected, `invented declaration passed: ${JSON.stringify(declaration)}`);61+ }62+});
M package/testdata/starter.snapshot.json +2 -2
M package/tooling/pwa-validation.ts +77 -0
@@ -23,6 +23,15 @@ const launchClientModes = new Set([
2323 "navigate-existing",2424 "navigate-new",2525 ]);26+const relatedApplicationPlatforms = new Set([27+ "amazon",28+ "chrome_web_store",29+ "chromeos_play",30+ "f-droid",31+ "play",32+ "webapp",33+ "windows",34+]);2635 const imageTypes = new Map([2736 ["image/jpeg", new Set([".jpeg", ".jpg"])],2837 ["image/png", new Set([".png"])],
@@ -292,6 +301,74 @@ async function parseAndValidateManifest(
292301 ),293302 );294303 }304+ if (manifest.prefer_related_applications !== undefined) {305+ if (typeof manifest.prefer_related_applications !== "boolean") {306+ issues.push(307+ issue(`${manifestFile}: prefer_related_applications must be boolean`, remediation),308+ );309+ } else if (manifest.prefer_related_applications) {310+ issues.push(311+ issue(312+ `${manifestFile}: prefer_related_applications must stay false to preserve PWA installability`,313+ remediation,314+ ),315+ );316+ }317+ }318+319+ if (manifest.related_applications !== undefined) {320+ if (321+ !Array.isArray(manifest.related_applications) || manifest.related_applications.length === 0322+ ) {323+ issues.push(324+ issue(`${manifestFile}: related_applications must be a non-empty array`, remediation),325+ );326+ } else {327+ const declarations = new Set<string>();328+ for (const [index, raw] of manifest.related_applications.entries()) {329+ const label = `${manifestFile}: related_applications[${index}]`;330+ if (!isObject(raw)) {331+ issues.push(issue(`${label} must be an object`, remediation));332+ continue;333+ }334+ const unknown = Object.keys(raw).filter((member) =>335+ !["platform", "id", "url"].includes(member)336+ );337+ if (unknown.length > 0) {338+ issues.push(issue(`${label} contains unsupported members`, remediation));339+ }340+ if (!nonEmptyString(raw.platform) || !relatedApplicationPlatforms.has(raw.platform)) {341+ issues.push(issue(`${label}.platform is not supported by this recipe`, remediation));342+ }343+ const hasId = nonEmptyString(raw.id) && raw.id.length <= 512;344+ const hasUrl = nonEmptyString(raw.url) && raw.url.length <= 512;345+ if (!hasId && !hasUrl) {346+ issues.push(issue(`${label} needs a bounded id or HTTPS url`, remediation));347+ }348+ let normalizedUrl = "";349+ if (raw.url !== undefined) {350+ if (!hasUrl) {351+ issues.push(issue(`${label}.url must be a bounded non-empty string`, remediation));352+ } else {353+ try {354+ const url = new URL(raw.url as string);355+ if (url.protocol !== "https:" || url.username || url.password) {356+ issues.push(issue(`${label}.url must be credential-free HTTPS`, remediation));357+ } else normalizedUrl = url.href;358+ } catch {359+ issues.push(issue(`${label}.url is not a valid URL`, remediation));360+ }361+ }362+ }363+ if (raw.id !== undefined && !hasId) {364+ issues.push(issue(`${label}.id must be a bounded non-empty string`, remediation));365+ }366+ const key = JSON.stringify([raw.platform, hasId ? raw.id : "", normalizedUrl]);367+ if (declarations.has(key)) issues.push(issue(`${label} must not repeat`, remediation));368+ declarations.add(key);369+ }370+ }371+ }295372 for (const member of ["background_color", "theme_color"] as const) {296373 if (!isLikelyCssColor(manifest[member])) {297374 issues.push(issue(`${manifestFile}: ${member} must be a CSS color`, remediation));
M package/tooling/pwa-validation_test.ts +31 -0
@@ -268,6 +268,37 @@ Deno.test("source PWA validation checks opt-in protocol handlers", async () => {
268268 }269269 });270270 271+Deno.test("source PWA validation checks opt-in related applications without preferring them", async () => {272+ const { root, project } = await makeProject();273+ try {274+ const manifest = await readManifest(project);275+ manifest.prefer_related_applications = true;276+ manifest.related_applications = [{277+ platform: "invented",278+ url: "http://user:secret@store.invalid/app",279+ extra: true,280+ }];281+ await writeManifest(project, manifest);282+ const issues = await sourcePwaIssues(project);283+ assert(hasIssue(issues, "preserve PWA installability"), "native preference was accepted");284+ assert(hasIssue(issues, "platform is not supported"), "invented platform was accepted");285+ assert(hasIssue(issues, "credential-free HTTPS"), "unsafe related URL was accepted");286+ assert(hasIssue(issues, "contains unsupported members"), "unknown related member passed");287+288+ manifest.prefer_related_applications = false;289+ manifest.related_applications = [{290+ platform: "play",291+ id: "com.example.companion",292+ url: "https://play.google.com/store/apps/details?id=com.example.companion",293+ }];294+ await writeManifest(project, manifest);295+ const valid = await sourcePwaIssues(project);296+ assert(valid.length === 0, `valid related app was rejected: ${JSON.stringify(valid)}`);297+ } finally {298+ await Deno.remove(root, { recursive: true });299+ }300+});301+271302 Deno.test("source PWA validation permits replacement branding and optional members", async () => {272303 const { root, project } = await makeProject();273304 try {
M tools/doc_lint.ts +1 -0
M tools/golden.ts +88 -1
@@ -392,7 +392,7 @@ async function assertGeneratedBoundary(
392392 const lofiImports = Object.entries(config.imports).filter(([name]) =>393393 name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")394394 );395- assert(lofiImports.length === 16, `expected 16 lofi imports, received ${lofiImports.length}`);395+ assert(lofiImports.length === 17, `expected 17 lofi imports, received ${lofiImports.length}`);396396 const expected = source === "registry"397397 ? `jsr:@nzip/lofi@${version}`398398 : pathToFileURL(join(repositoryRoot, "package")).href;
@@ -914,6 +914,41 @@ export default function ProtocolItem() {
914914 );915915 }916916 917+async function installRelatedAppDiscoveryRecipe(projectRoot: string) {918+ const manifestPath = join(projectRoot, "public", "manifest.webmanifest");919+ const manifest = JSON.parse(await Deno.readTextFile(manifestPath));920+ manifest.prefer_related_applications = false;921+ manifest.related_applications = [{922+ platform: "play",923+ id: "com.example.companion",924+ url: "https://play.google.com/store/apps/details?id=com.example.companion",925+ }];926+ await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);927+ await Deno.writeTextFile(928+ join(projectRoot, "src", "pages", "companion.astro"),929+ `---930+import CompanionDiscovery from "../islands/CompanionDiscovery.tsx";931+import Shell from "../layouts/Shell.astro";932+import "../styles/global.css";933+---934+<Shell title="Companion app"><CompanionDiscovery client:load /></Shell>935+`,936+ );937+ await Deno.writeTextFile(938+ join(projectRoot, "src", "islands", "CompanionDiscovery.tsx"),939+ `import { useEffect, useState } from "preact/hooks";940+import { discoverRelatedApplications, type RelatedApplicationDiscovery } from "@nzip/lofi/recipes/related-app-discovery";941+const allow = [{ platform: "play", id: "com.example.companion", url: "https://play.google.com/store/apps/details?id=com.example.companion" }] as const;942+export default function CompanionDiscovery() {943+ const [result, setResult] = useState<RelatedApplicationDiscovery>();944+ useEffect(() => { void discoverRelatedApplications({ allow }).then(setResult); }, []);945+ const installed = result?.status === "installed";946+ return <section class="island"><h1>Companion app</h1><output data-related-status>{result?.status ?? "checking"}</output>{!installed && <p data-related-onboarding>Get the companion app</p>}{installed && <p data-related-installed>Companion already installed</p>}<p data-related-auth>Sign-in and permissions are unchanged.</p></section>;947+}948+`,949+ );950+}951+917952 async function main() {918953 const { source, version } = parseArgs(Deno.args);919954 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {
@@ -1164,6 +1199,10 @@ async function main() {
11641199 "install protocol-handler recipe",11651200 () => installProtocolHandlerRecipe(projectRoot!),11661201 );1202+ await stage(1203+ "install related-app-discovery recipe",1204+ () => installRelatedAppDiscoveryRecipe(projectRoot!),1205+ );11671206 11681207 await stage(11691208 "build",
@@ -1545,6 +1584,54 @@ async function main() {
15451584 description: "task app after protocol-handler exercise",15461585 });15471586 });1587+ await stage("related-app-discovery recipe browser", async () => {1588+ const unsupportedPage = await context!.newPage();1589+ try {1590+ await unsupportedPage.addInitScript(() => {1591+ Object.defineProperty(navigator, "getInstalledRelatedApps", {1592+ configurable: true,1593+ value: undefined,1594+ });1595+ });1596+ await unsupportedPage.goto(`${origin}companion/`, { waitUntil: "domcontentloaded" });1597+ await unsupportedPage.locator("[data-related-onboarding]").waitFor({ state: "visible" });1598+ await waitForReady(1599+ unsupportedPage,1600+ () => document.querySelector("[data-related-status]")?.textContent === "unsupported",1601+ undefined,1602+ { description: "unsupported related-app discovery" },1603+ );1604+ } finally {1605+ await unsupportedPage.close();1606+ }1607+ await page!.addInitScript(() => {1608+ Object.defineProperty(navigator, "getInstalledRelatedApps", {1609+ configurable: true,1610+ value: () =>1611+ Promise.resolve([{1612+ platform: "play",1613+ id: "com.example.companion",1614+ url: "https://play.google.com/store/apps/details?id=com.example.companion",1615+ version: "private-version",1616+ }]),1617+ });1618+ });1619+ await page!.goto(`${origin}companion/`, { waitUntil: "domcontentloaded" });1620+ await page!.locator("[data-related-installed]").waitFor({ state: "visible" });1621+ assert(1622+ await page!.locator("[data-related-auth]").textContent() ===1623+ "Sign-in and permissions are unchanged.",1624+ "discovery presentation implied an auth or authorization change",1625+ );1626+ assert(1627+ !(await page!.locator("body").innerText()).includes("private-version"),1628+ "browser-supplied companion version leaked into presentation",1629+ );1630+ await page!.goto(origin, { waitUntil: "domcontentloaded" });1631+ await waitForReady(page!, taskAppReady, undefined, {1632+ description: "task app after related-app discovery exercise",1633+ });1634+ });15481635 await stage("waiting worker update browser", async () => {15491636 const workerPath = join(projectRoot!, "dist", "sw.js");15501637 const worker = await Deno.readTextFile(workerPath);