From d8ce4c11cc65913e459e52e27fc224e81b844462 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 08:11:13 +0100 Subject: [PATCH 1/6] test(graph-web): serve the app under a sub-path base in the end-to-end tests A second build with GRAPH_WEB_BASE set to a project-site sub-path is made in Playwright's global setup and previewed on its own port. The test checks that the shell, manifest scope, service worker scope and icons are served under the base and that no same-origin request falls outside it. GRAPH_WEB_BASE is declared in the _build task's env so turbo passes it to Vite and keys its cache on it. --- .gitignore | 2 + .../targets/graph-web/e2e/base-path-build.ts | 31 ++++++++++++++ .../graph-web/e2e/base-path.e2e.test.ts | 41 +++++++++++++++++++ packages/targets/graph-web/e2e/base-path.ts | 8 ++++ packages/targets/graph-web/eslint.config.ts | 7 +++- .../targets/graph-web/playwright.config.ts | 18 +++++++- packages/targets/graph-web/turbo.json | 1 + 7 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 packages/targets/graph-web/e2e/base-path-build.ts create mode 100644 packages/targets/graph-web/e2e/base-path.e2e.test.ts create mode 100644 packages/targets/graph-web/e2e/base-path.ts diff --git a/.gitignore b/.gitignore index eed9565..d8227e7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ reports test-results playwright-report dev-dist +# The sub-path build the web app's base-path end-to-end test serves. +dist-base-path # Un-ignore AGENTS.md/CLAUDE.md against a machine-wide global gitignore that excludes them everywhere by default. README.md symlinked to AGENTS.md and CLAUDE.md is this repository's deliberate "one instruction file" pattern, so these symlinks must be tracked. !AGENTS.md diff --git a/packages/targets/graph-web/e2e/base-path-build.ts b/packages/targets/graph-web/e2e/base-path-build.ts new file mode 100644 index 0000000..a2c4749 --- /dev/null +++ b/packages/targets/graph-web/e2e/base-path-build.ts @@ -0,0 +1,31 @@ +import { build, preview } from "vite"; +import { basePath, basePathOutDir, basePathPreviewPort } from "./base-path.ts"; + +const configFile = new URL("../vite.config.ts", import.meta.url).pathname; + +/** + * Playwright's global setup: builds the app a second time with `GRAPH_WEB_BASE` set to the sub-path, into its own output directory, and previews it on its own port for the base-path end-to-end test. This is not a `webServer` entry because Playwright starts those before global setup, when the build does not exist yet. The variable is set on this process before Vite loads `vite.config.ts`, which reads it once. + * @returns The teardown, which stops the preview server. + */ +export default async function serveAtBasePath(): Promise<() => Promise> { + process.env.GRAPH_WEB_BASE = basePath; + await build({ + configFile, + logLevel: "warn", + build: { outDir: basePathOutDir, emptyOutDir: true }, + }); + const server = await preview({ + configFile, + logLevel: "warn", + build: { outDir: basePathOutDir }, + preview: { + host: "127.0.0.1", + port: basePathPreviewPort, + strictPort: true, + }, + }); + + return async () => { + await server.close(); + }; +} diff --git a/packages/targets/graph-web/e2e/base-path.e2e.test.ts b/packages/targets/graph-web/e2e/base-path.e2e.test.ts new file mode 100644 index 0000000..18dba1a --- /dev/null +++ b/packages/targets/graph-web/e2e/base-path.e2e.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; +import { basePath } from "./base-path.ts"; + +// Runs against the build made with `GRAPH_WEB_BASE` set to a sub-path (see base-path-build.ts), served as a project site serves it, under the sub-path. + +test("serves the shell, manifest and service worker under the base path and requests nothing outside it", async ({ + page, + baseURL, +}) => { + const origin = new URL(baseURL ?? "").origin; + const paths: string[] = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.origin === origin) { + paths.push(url.pathname); + } + }); + + await page.goto(basePath); + await expect(page.getByLabel("Token", { exact: true })).toBeVisible(); + const scope = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + + return registration.scope; + }); + expect(scope).toBe(`${origin}${basePath}`); + + const manifestLink = await page + .locator('link[rel="manifest"]') + .getAttribute("href"); + expect(manifestLink).toBe(`${basePath}manifest.webmanifest`); + const manifest: unknown = await ( + await page.request.get(`${basePath}manifest.webmanifest`) + ).json(); + expect(manifest).toMatchObject({ start_url: basePath, scope: basePath }); + const icon = await page.request.get(`${basePath}icons/maskable-512.png`); + expect(icon.headers()["content-type"]).toBe("image/png"); + + expect(paths).not.toHaveLength(0); + expect(paths.filter((path) => !path.startsWith(basePath))).toEqual([]); +}); diff --git a/packages/targets/graph-web/e2e/base-path.ts b/packages/targets/graph-web/e2e/base-path.ts new file mode 100644 index 0000000..60fbfa9 --- /dev/null +++ b/packages/targets/graph-web/e2e/base-path.ts @@ -0,0 +1,8 @@ +/** The sub-path a project site serves the app under (`https://.github.io/process-graph/`), which the base-path build and its tests use. */ +export const basePath = "/process-graph/"; + +/** Where the base-path build is written, beside `dist/` so it never disturbs the root build the other end-to-end tests serve. */ +export const basePathOutDir = "dist-base-path"; + +/** The port the base-path build is previewed on, pinned with `--strictPort` like the root build's. */ +export const basePathPreviewPort = 4174; diff --git a/packages/targets/graph-web/eslint.config.ts b/packages/targets/graph-web/eslint.config.ts index 13ba924..afd37f1 100644 --- a/packages/targets/graph-web/eslint.config.ts +++ b/packages/targets/graph-web/eslint.config.ts @@ -11,6 +11,11 @@ export default defineConfig( isomorphic: true, browserGlobals: true, react: { version }, - additionalIgnores: ["dev-dist", "playwright-report", "test-results"], + additionalIgnores: [ + "dev-dist", + "dist-base-path", + "playwright-report", + "test-results", + ], }), ); diff --git a/packages/targets/graph-web/playwright.config.ts b/packages/targets/graph-web/playwright.config.ts index d74e381..4c26cfd 100644 --- a/packages/targets/graph-web/playwright.config.ts +++ b/packages/targets/graph-web/playwright.config.ts @@ -1,12 +1,16 @@ import { defineConfig } from "@playwright/test"; +import { basePathPreviewPort } from "./e2e/base-path.ts"; /** Vite's own default port for `vite preview`, pinned with `--strictPort` so the tests never drive another server that happens to hold it. */ const previewPort = 4173; const baseURL = `http://127.0.0.1:${String(previewPort)}`; +const basePathURL = `http://127.0.0.1:${String(basePathPreviewPort)}`; /** * End-to-end tests against the production build, served by `vite preview` as a static host would serve it, with no cross-origin isolation headers. The build itself comes from the `_build` task that `_test:e2e` depends on. Chromium is the browser CI installs for graph-sqlite-wasm's browser tests, at the version this Playwright expects. GitHub's API is answered by a local fixture server the tests start, reached by routing the browser's requests to api.github.com, so the browser still addresses GitHub and the token checks see the real request. * + * A second build, made in global setup with the base set to a sub-path and previewed on its own port, is what `base-path.e2e.test.ts` runs against: the same app as a project site serves it. + * * `--host 127.0.0.1`: Vite binds `localhost`, which can resolve to `::1` only, where the IPv4 base URL would be refused. */ export default defineConfig({ @@ -15,10 +19,22 @@ export default defineConfig({ reporter: "list", // A fixed locale and time zone, so dates the app formats read the same on every machine. use: { baseURL, locale: "en-GB", timezoneId: "UTC" }, + globalSetup: "./e2e/base-path-build.ts", webServer: { command: `pnpm exec vite preview --host 127.0.0.1 --port ${String(previewPort)} --strictPort`, url: baseURL, reuseExistingServer: false, }, - projects: [{ name: "chromium", use: { browserName: "chromium" } }], + projects: [ + { + name: "chromium", + testIgnore: "base-path.e2e.test.ts", + use: { browserName: "chromium" }, + }, + { + name: "base-path", + testMatch: "base-path.e2e.test.ts", + use: { browserName: "chromium", baseURL: basePathURL }, + }, + ], }); diff --git a/packages/targets/graph-web/turbo.json b/packages/targets/graph-web/turbo.json index 4604188..2be79ee 100644 --- a/packages/targets/graph-web/turbo.json +++ b/packages/targets/graph-web/turbo.json @@ -3,6 +3,7 @@ "extends": ["//"], "tasks": { "_build": { + "env": ["GRAPH_WEB_BASE"], "inputs": ["$TURBO_EXTENDS$", "index.html", "build/**", "vite.config.ts"] }, "_test:e2e": { From e416ea10b423a65bda43fa80b8b413759e9b29c8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 08:12:12 +0100 Subject: [PATCH 2/6] ci: deploy the web app to GitHub Pages on push to main Builds graph-web with the base set to the repository name and publishes it with the official Pages actions, pinned by SHA. The deploy job alone holds pages and id-token write and queues behind a running deploy rather than cancelling it. --- .github/workflows/pages.yml | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..282196c --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,47 @@ +name: Pages + +# Deploys the web app (packages/targets/graph-web) to GitHub Pages as a project site, https://exadev.github.io/process-graph/. The repository's Pages source must be set to "GitHub Actions" once (`gh api repos/ExaDev/process-graph/pages -X POST -f build_type=workflow`); until it is, the deploy job fails at the deployment step. + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The app's base path is the repository name, so the build serves under /process-graph/ and its manifest scope, service worker scope and asset URLs all follow it. GRAPH_WEB_BASE is read by vite.config.ts and declared in the _build task's env so turbo passes it through and keys its cache on it. + - uses: ./.github/actions/setup-workspace + with: + task: pages + command: GRAPH_WEB_BASE="/${GITHUB_REPOSITORY#*/}/" pnpm exec turbo run _build --filter=@exadev/process-graph-web + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: packages/targets/graph-web/dist + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + # Two deploys never overlap and a running one is never cancelled: a cancelled deployment can leave the site half-published, so the next run queues behind it. + concurrency: + group: pages + cancel-in-progress: false + permissions: + pages: write # to publish the artifact + id-token: write # OIDC proof to the Pages service that the deploy comes from this workflow + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 From 565f8b7905d337d038b3dd7eb831f8dfe2041546 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 08:12:28 +0100 Subject: [PATCH 3/6] docs(graph-web): document the Pages deployment and how its base is set --- packages/targets/graph-web/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/targets/graph-web/README.md b/packages/targets/graph-web/README.md index 68fa79d..cb269c9 100644 --- a/packages/targets/graph-web/README.md +++ b/packages/targets/graph-web/README.md @@ -59,6 +59,12 @@ The service worker precaches the app shell: the page, scripts, styles, icons and `pnpm build` runs `vite build`. `build/icons.ts` renders the icons at build time: one SVG glyph in the theme contract's colours, rasterised by resvg to the 192 and 512 pixel PNGs, `any` and `maskable`, that Chromium's install criteria ask for, and emitted into the bundle, so no generated image is committed. The manifest's theme and background colours come from graph-ui's theme values. `GRAPH_WEB_BASE` sets the base path the app is served under, such as `/process-graph/`; the manifest's `start_url` and `scope` follow it. +## Deployment + +`.github/workflows/pages.yml` builds the app on every push to `main` and deploys it to GitHub Pages as a project site, at `https://exadev.github.io/process-graph/`. The workflow sets `GRAPH_WEB_BASE` to `//`; without it the app builds for the root, which is what `vite preview`, the tests and any host serving the app at the top of its own origin use. Vite's `base` takes the value, and the manifest's `start_url` and `scope`, the service worker's scope and precache, the icons and the page's own asset links are all relative to it. `e2e/base-path.e2e.test.ts` builds the app with the base set and checks that its shell, manifest, service worker scope and icons are served under it and that nothing is requested outside it. + +The repository's Pages source must be set to GitHub Actions once, by someone with admin rights: `gh api repos/ExaDev/process-graph/pages -X POST -f build_type=workflow`. Until then the deploy job fails. + ## Tests The screen tests render the app with Testing Library in happy-dom against the store contract's in-memory fake and the source contract's fake source, each through its oRPC client, laid out by a stub engine. `test/app.smoke.test.ts` checks the built `dist/`: the manifest, the rendered icons and the precache. From 0399e199f03067153df33710f60b999eec5c41fe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 08:54:20 +0100 Subject: [PATCH 4/6] feat(graph-web): keep nothing on a shared github.io origin A pure persistence policy, decided once in the composition root, forbids persisting when the hostname ends in .github.io, where every project site shares one origin and URL paths do not partition localStorage, IndexedDB or the origin private file system. In that mode the token and theme preference live in memory, sync records are not saved, the store is the store contract's in-memory fake instead of the OPFS SQLite worker, no service worker is registered, and a notice that cannot be dismissed says why. The end-to-end fixture, helpers and test are shared through e2e/support.ts so the new shared-origin test and the existing ones use one definition. --- .../targets/graph-web/e2e/app.e2e.test.ts | 72 ++---------- .../graph-web/e2e/shared-origin.e2e.test.ts | 81 ++++++++++++++ packages/targets/graph-web/e2e/support.ts | 62 +++++++++++ packages/targets/graph-web/src/app.tsx | 3 + .../targets/graph-web/src/app.unit.test.tsx | 31 ++++++ packages/targets/graph-web/src/main.tsx | 32 ++++-- packages/targets/graph-web/src/notices.tsx | 14 +++ packages/targets/graph-web/src/persistence.ts | 76 +++++++++++++ .../graph-web/src/persistence.unit.test.ts | 104 ++++++++++++++++++ packages/targets/graph-web/src/ports.ts | 2 + .../graph-web/src/test-support/ports.ts | 3 + .../targets/graph-web/src/token-panel.tsx | 14 ++- 12 files changed, 419 insertions(+), 75 deletions(-) create mode 100644 packages/targets/graph-web/e2e/shared-origin.e2e.test.ts create mode 100644 packages/targets/graph-web/e2e/support.ts create mode 100644 packages/targets/graph-web/src/persistence.ts create mode 100644 packages/targets/graph-web/src/persistence.unit.test.ts diff --git a/packages/targets/graph-web/e2e/app.e2e.test.ts b/packages/targets/graph-web/e2e/app.e2e.test.ts index b41226b..8f2dd5d 100644 --- a/packages/targets/graph-web/e2e/app.e2e.test.ts +++ b/packages/targets/graph-web/e2e/app.e2e.test.ts @@ -1,67 +1,15 @@ +import { type Request } from "@playwright/test"; +import { fixtureToken, rateLimitedToken } from "./github-fixture.ts"; import { - expect, - test as base, - type Page, - type Request, -} from "@playwright/test"; -import { - fixtureToken, - rateLimitedToken, - startGitHubFixture, - type GitHubFixture, -} from "./github-fixture.ts"; - -const githubApiHost = "api.github.com"; - -/** Every test gets its own fixture server, and its browser context's requests to GitHub's API are answered by it. Playwright forwards the request from the test process, so the browser's own request still goes to api.github.com. */ -const test = base.extend<{ readonly github: GitHubFixture }>({ - github: [ - async ({ context }, use) => { - const fixture = await startGitHubFixture(); - await context.route(`https://${githubApiHost}/**`, async (route) => { - const response = await route.fetch({ url: fixture.url }); - await route.fulfill({ response }); - }); - await use(fixture); - await fixture.close(); - }, - { auto: true }, - ], -}); - -/** An element's card on the canvas. */ -const card = (page: Page, id: string) => - page.locator(`.react-flow__node [data-element-id="${id}"]`); + card, + githubApiHost, + serviceWorkerActive, + syncFinished, + syncScopes, + test, +} from "./support.ts"; -/** Saves a token, adds the scopes and starts their sync. */ -const syncScopes = async ( - page: Page, - token: string, - scopes: readonly string[], -): Promise => { - await page.getByLabel("Token", { exact: true }).fill(token); - await page.getByRole("button", { name: "Save on this device" }).click(); - // Each scope is added after the one before, as a person would add them. - await scopes.reduce(async (previous, scope) => { - await previous; - await page - .getByLabel("Add an organisation, user or repository") - .fill(scope); - await page.getByRole("button", { name: "Add", exact: true }).click(); - }, Promise.resolve()); - await page.getByRole("button", { name: "Sync", exact: true }).click(); -}; - -const syncFinished = async (page: Page): Promise => { - await expect(page.locator("[data-progress]")).toContainText("Sync finished"); -}; - -/** Waits until a service worker is active for the page's scope, so a reload is served from its precache. */ -const serviceWorkerActive = async (page: Page): Promise => { - await page.evaluate(async () => { - await navigator.serviceWorker.ready; - }); -}; +import { expect } from "@playwright/test"; test("syncs organisations across their repositories, draws the work and lists what is ready and blocked", async ({ page, diff --git a/packages/targets/graph-web/e2e/shared-origin.e2e.test.ts b/packages/targets/graph-web/e2e/shared-origin.e2e.test.ts new file mode 100644 index 0000000..126d51e --- /dev/null +++ b/packages/targets/graph-web/e2e/shared-origin.e2e.test.ts @@ -0,0 +1,81 @@ +import { expect, type Page } from "@playwright/test"; +import { fixtureToken } from "./github-fixture.ts"; +import { card, syncFinished, syncScopes, test } from "./support.ts"; + +const sharedOrigin = "https://exadev.github.io"; + +/** What the page has kept on the device, read from the browser's own storage APIs. */ +const kept = async (page: Page) => + page.evaluate(async () => { + const opfsRoot = await navigator.storage.getDirectory(); + const opfsEntries: string[] = []; + for await (const name of opfsRoot.keys()) { + opfsEntries.push(name); + } + + return { + localStorage: window.localStorage.length, + sessionStorage: window.sessionStorage.length, + databases: (await indexedDB.databases()).length, + caches: (await caches.keys()).length, + opfsEntries, + serviceWorkers: (await navigator.serviceWorker.getRegistrations()).length, + }; + }); + +test("on a shared github.io origin a sync works and persists nothing", async ({ + page, + context, + baseURL, +}) => { + // The browser addresses the shared origin; each request is answered with the same path from the local preview of the root build. + await context.route(`${sharedOrigin}/**`, async (route) => { + const { pathname, search } = new URL(route.request().url()); + const response = await route.fetch({ + url: `${baseURL ?? ""}${pathname}${search}`, + }); + await route.fulfill({ response }); + }); + const workers: string[] = []; + page.on("worker", (worker) => { + workers.push(worker.url()); + }); + + await page.goto(`${sharedOrigin}/`); + await expect(page.locator('[data-notice="ephemeral"]')).toContainText( + "keeps nothing between visits", + ); + await syncScopes(page, fixtureToken, ["acme"], "Use for this visit"); + await syncFinished(page); + await expect(card(page, "github:acme/web#1")).toHaveAttribute( + "data-status", + "blocked", + ); + + expect(await kept(page)).toEqual({ + localStorage: 0, + sessionStorage: 0, + databases: 0, + caches: 0, + opfsEntries: [], + serviceWorkers: 0, + }); + expect(workers).toEqual([]); + + // Nothing survives a reload: the token is asked for again and the sync is gone. + await page.reload(); + await expect(page.getByLabel("Token", { exact: true })).toBeVisible(); + await expect(card(page, "github:acme/web#1")).toHaveCount(0); +}); + +test("the same flow on an origin of its own persists, so the check above can fail", async ({ + page, +}) => { + await page.goto("/"); + await syncScopes(page, fixtureToken, ["acme"]); + await syncFinished(page); + + const state = await kept(page); + expect(state.localStorage).toBeGreaterThan(0); + expect(state.opfsEntries).not.toEqual([]); +}); diff --git a/packages/targets/graph-web/e2e/support.ts b/packages/targets/graph-web/e2e/support.ts new file mode 100644 index 0000000..35ee268 --- /dev/null +++ b/packages/targets/graph-web/e2e/support.ts @@ -0,0 +1,62 @@ +import { + expect, + test as base, + type Locator, + type Page, +} from "@playwright/test"; +import { startGitHubFixture, type GitHubFixture } from "./github-fixture.ts"; + +export const githubApiHost = "api.github.com"; + +// What the end-to-end tests that run the app against the GitHub fixture share. + +/** Every test gets its own fixture server, and its browser context's requests to GitHub's API are answered by it. Playwright forwards the request from the test process, so the browser's own request still goes to api.github.com. */ +export const test = base.extend<{ readonly github: GitHubFixture }>({ + github: [ + async ({ context }, use) => { + const fixture = await startGitHubFixture(); + await context.route(`https://${githubApiHost}/**`, async (route) => { + const response = await route.fetch({ url: fixture.url }); + await route.fulfill({ response }); + }); + await use(fixture); + await fixture.close(); + }, + { auto: true }, + ], +}); + +/** An element's card on the canvas. */ +export const card = (page: Page, id: string): Locator => + page.locator(`.react-flow__node [data-element-id="${id}"]`); + +/** Saves a token, adds the scopes and starts their sync. */ +export const syncScopes = async ( + page: Page, + token: string, + scopes: readonly string[], + saveButton = "Save on this device", +): Promise => { + await page.getByLabel("Token", { exact: true }).fill(token); + await page.getByRole("button", { name: saveButton }).click(); + // Each scope is added after the one before, as a person would add them. + await scopes.reduce(async (previous, scope) => { + await previous; + await page + .getByLabel("Add an organisation, user or repository") + .fill(scope); + await page.getByRole("button", { name: "Add", exact: true }).click(); + }, Promise.resolve()); + await page.getByRole("button", { name: "Sync", exact: true }).click(); +}; + +export const syncFinished = async (page: Page): Promise => { + await expect(page.locator("[data-progress]")).toContainText("Sync finished"); +}; + +/** Waits until a service worker is active for the page's scope, so a reload is served from its precache. */ +export const serviceWorkerActive = async (page: Page): Promise => { + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + }); +}; diff --git a/packages/targets/graph-web/src/app.tsx b/packages/targets/graph-web/src/app.tsx index f948577..9eb357f 100644 --- a/packages/targets/graph-web/src/app.tsx +++ b/packages/targets/graph-web/src/app.tsx @@ -23,6 +23,7 @@ import { FailureNotice, GapsNotice, LinkProblems, + EphemeralNotice, OfflineNotice, UpdateNotice, } from "./notices.tsx"; @@ -165,6 +166,7 @@ export const App = (props: AppProps): ReactElement => { GitHub token + {ports.persistence === "ephemeral" ? : null} {online ? null : } {updateAvailable ? ( @@ -184,6 +186,7 @@ export const App = (props: AppProps): ReactElement => { {tokenOpen || !hasToken ? ( { ports.token.write(token); setHasToken(true); diff --git a/packages/targets/graph-web/src/app.unit.test.tsx b/packages/targets/graph-web/src/app.unit.test.tsx index e167b16..25de81f 100644 --- a/packages/targets/graph-web/src/app.unit.test.tsx +++ b/packages/targets/graph-web/src/app.unit.test.tsx @@ -53,6 +53,37 @@ const cardOf = async (id: string): Promise => return card; }); +describe("an ephemeral copy", () => { + test("says at the top that nothing is kept between visits, without a way to dismiss it, and takes the token for the visit only", () => { + const ports = testPorts({ persistence: "ephemeral" }); + renderApp(ports); + + const notice = document.querySelector('[data-notice="ephemeral"]'); + expect(notice?.textContent).toContain("keeps nothing between visits"); + expect(notice?.textContent).toContain("need an address of its own"); + expect(notice?.querySelector("button")).toBeNull(); + const panel = screen.getByRole("region", { name: "GitHub token" }); + expect(panel.textContent).toContain("held in this page's memory only"); + expect( + within(panel).queryByRole("button", { name: "Save on this device" }), + ).toBeNull(); + fireEvent.change(within(panel).getByLabelText("Token"), { + target: { value: "secret-token" }, + }); + fireEvent.click( + within(panel).getByRole("button", { name: "Use for this visit" }), + ); + + expect(document.querySelector('[data-notice="ephemeral"]')).not.toBeNull(); + }); + + test("is not announced on a device that keeps its data", () => { + renderApp(testPorts()); + + expect(document.querySelector('[data-notice="ephemeral"]')).toBeNull(); + }); +}); + describe("the token screen", () => { test("is shown until a token is saved, says what the token needs and where it goes, and never shows it back", () => { const ports = testPorts(); diff --git a/packages/targets/graph-web/src/main.tsx b/packages/targets/graph-web/src/main.tsx index ad5e45e..53ae8fd 100644 --- a/packages/targets/graph-web/src/main.tsx +++ b/packages/targets/graph-web/src/main.tsx @@ -21,6 +21,11 @@ import { serviceWorkerUpdates, systemPrefersDark, } from "./browser.ts"; +import { + ephemeralPersistence, + persistenceAllowed, + type PersistencePorts, +} from "./persistence.ts"; import type { AppPorts } from "./ports.ts"; // The composition root: the only module that knows which store, sources and browser APIs the app runs on. Every screen reaches them through `AppPorts`. @@ -30,13 +35,26 @@ if (root === null) { throw new Error("index.html must contain a #root element to mount into"); } -/** The store, in a dedicated worker on SQLite wasm over the origin private file system, reached over the store contract's oRPC client. */ -const store = connectGraphStore( - new Worker(new URL("store.worker.ts", import.meta.url), { type: "module" }), -); +/** + * What outlives the page, on an origin of its own: the store in a dedicated worker on SQLite wasm over the origin private file system, reached over the store contract's oRPC client; the token, sync records and theme preference in local storage; and the service worker. + * @returns The ports. + */ +const devicePersistence = (): PersistencePorts => ({ + store: connectGraphStore( + new Worker(new URL("store.worker.ts", import.meta.url), { type: "module" }), + ), + token: localTokenStore(window.localStorage), + syncRecords: localSyncRecords(window.localStorage), + themePreference: localThemePreference(window.localStorage), + updates: serviceWorkerUpdates(registerSW), +}); + +// Whether anything outlives the page is decided once, here, from the origin: on a shared github.io origin the store, the token, the settings and the service worker are all in memory or absent, so the worker and `registerSW` are never started. +const persistent = persistenceAllowed(window.location); const ports: AppPorts = { - store, + persistence: persistent ? "device" : "ephemeral", + ...(persistent ? devicePersistence() : ephemeralPersistence()), // The GitHub source runs in the page, called through the source contract's in-process oRPC client. The token goes only into the adapter's Authorization header for requests to GitHub's API, and the fetch it calls reaches nothing else. sources: (token) => (prefix) => prefix === githubSource @@ -49,13 +67,9 @@ const ports: AppPorts = { ), ) : undefined, - token: localTokenStore(window.localStorage), - syncRecords: localSyncRecords(window.localStorage), - themePreference: localThemePreference(window.localStorage), address: browserAddressBar(window), online: browserOnline(window), prefersDark: systemPrefersDark(window), - updates: serviceWorkerUpdates(registerSW), download: browserDownload(document), }; diff --git a/packages/targets/graph-web/src/notices.tsx b/packages/targets/graph-web/src/notices.tsx index b5c6040..2e4b691 100644 --- a/packages/targets/graph-web/src/notices.tsx +++ b/packages/targets/graph-web/src/notices.tsx @@ -14,6 +14,20 @@ export const OfflineNotice = (): ReactElement => (

); +/** + * Says this copy of the app keeps nothing between visits, because the origin it is served from is shared with other sites. It has no dismiss control: what it says holds for the whole visit. + * @returns The notice. + */ +export const EphemeralNotice = (): ReactElement => ( +

+ This copy keeps nothing between visits: its address is shared with other + sites, which could read anything it saved. The token, the synced issues and + your settings are held in memory and are gone when you close or reload the + page. Installing the app and using it offline need an address of its own, + for example running it locally from the repository. +

+); + export interface UpdateNoticeProps { readonly onReload: () => void; } diff --git a/packages/targets/graph-web/src/persistence.ts b/packages/targets/graph-web/src/persistence.ts new file mode 100644 index 0000000..d961adf --- /dev/null +++ b/packages/targets/graph-web/src/persistence.ts @@ -0,0 +1,76 @@ +import { + createFakeGraphStore, + createGraphStoreRouter, +} from "@exadev/process-graph-store-contract"; +import { createRouterClient } from "@orpc/server"; +import type { + AppPorts, + AppUpdates, + ThemePreference, + ThemePreferenceStore, + TokenStore, + SyncRecords, +} from "./ports.ts"; +import type { SyncRecord } from "./sync-record.ts"; + +/** The suffix of the hostnames GitHub Pages serves every account's and organisation's sites from. */ +const sharedPagesSuffix = ".github.io"; + +/** + * Whether the app may keep anything on the device for the origin it runs on. Every page under one `github.io` hostname shares an origin, and URL paths do not partition `localStorage`, IndexedDB or the origin private file system, so any other page there could read a saved token and the synced issues. On such an origin the app keeps nothing. + * @param location - The page's location, of which only the hostname is read. + * @returns False on a shared GitHub Pages origin, true anywhere else (localhost, an origin of one's own). + */ +export const persistenceAllowed = ( + location: Readonly>, +): boolean => !location.hostname.toLowerCase().endsWith(sharedPagesSuffix); + +/** The ports that decide what outlives the page, as the ephemeral mode supplies them. */ +export type PersistencePorts = Pick< + AppPorts, + "store" | "token" | "syncRecords" | "themePreference" | "updates" +>; + +/** + * Ports that hold everything in this page's memory and touch no storage: the token and the theme preference are variables, sync records a map, the store the store contract's in-memory fake, and there is no service worker and so never an update. All of it is gone when the page is. + * @returns The ports. + */ +export const ephemeralPersistence = (): PersistencePorts => { + let token: string | undefined; + let preference: ThemePreference = "system"; + const records = new Map(); + + const tokens: TokenStore = { + read: () => token, + write: (next) => { + token = next; + }, + clear: () => { + token = undefined; + }, + }; + const syncRecords: SyncRecords = { + read: (modelId) => records.get(modelId), + write: (record) => { + records.set(record.modelId, record); + }, + }; + const themePreference: ThemePreferenceStore = { + read: () => preference, + write: (next) => { + preference = next; + }, + }; + const updates: AppUpdates = { + available: { get: () => false, subscribe: () => () => undefined }, + apply: () => undefined, + }; + + return { + store: createRouterClient(createGraphStoreRouter(createFakeGraphStore())), + token: tokens, + syncRecords, + themePreference, + updates, + }; +}; diff --git a/packages/targets/graph-web/src/persistence.unit.test.ts b/packages/targets/graph-web/src/persistence.unit.test.ts new file mode 100644 index 0000000..2c9843a --- /dev/null +++ b/packages/targets/graph-web/src/persistence.unit.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ephemeralPersistence, persistenceAllowed } from "./persistence.ts"; + +/** The navigator properties a test replaced, removed again afterwards. */ +const replacedOnNavigator = ["storage", "serviceWorker"] as const; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + for (const property of replacedOnNavigator) { + Reflect.deleteProperty(window.navigator, property); + } +}); + +describe("the persistence policy", () => { + test.each(["exadev.github.io", "someone.github.io", "Someone.GitHub.io"])( + "forbids persisting on the shared origin %s", + (hostname) => { + expect(persistenceAllowed({ hostname })).toBe(false); + }, + ); + + test.each([ + "localhost", + "127.0.0.1", + "graph.example.com", + "github.io.example.com", + "notgithub.io", + ])("allows persisting on %s", (hostname) => { + expect(persistenceAllowed({ hostname })).toBe(true); + }); +}); + +/** A stand-in for a browser storage API that fails the test on any use. */ +const forbidden = (name: string): object => + new Proxy( + {}, + { + get: (_target, property) => { + throw new Error(`${name}.${String(property)} was used`); + }, + }, + ); + +describe("the ephemeral ports", () => { + test("keep the token, the settings and the sync records in memory and touch no browser storage", async () => { + const storageCalls = [ + vi.spyOn(Storage.prototype, "getItem"), + vi.spyOn(Storage.prototype, "setItem"), + vi.spyOn(Storage.prototype, "removeItem"), + vi.spyOn(Storage.prototype, "clear"), + ]; + vi.stubGlobal("indexedDB", forbidden("indexedDB")); + vi.stubGlobal("caches", forbidden("caches")); + for (const property of replacedOnNavigator) { + Object.defineProperty(window.navigator, property, { + configurable: true, + value: forbidden(`navigator.${property}`), + }); + } + + const ports = ephemeralPersistence(); + + expect(ports.token.read()).toBeUndefined(); + ports.token.write("a-token"); + expect(ports.token.read()).toBe("a-token"); + ports.token.clear(); + expect(ports.token.read()).toBeUndefined(); + + expect(ports.themePreference.read()).toBe("system"); + ports.themePreference.write("dark"); + expect(ports.themePreference.read()).toBe("dark"); + + const record = { modelId: "github:acme", gaps: [] }; + ports.syncRecords.write(record); + expect(ports.syncRecords.read("github:acme")).toBe(record); + expect(ports.syncRecords.read("github:other")).toBeUndefined(); + + expect(ports.updates.available.get()).toBe(false); + expect(() => { + ports.updates.apply(); + }).not.toThrow(); + + // The in-memory store answers, here with its own "no such model" for a model nothing has synced. + await expect( + ports.store.v1.query.ancestors({ + modelId: "github:acme", + element: "github:acme/web#1", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + for (const call of storageCalls) { + expect(call).not.toHaveBeenCalled(); + } + expect(window.localStorage).toHaveLength(0); + expect(window.sessionStorage).toHaveLength(0); + }); + + test("hold nothing between two instances", () => { + ephemeralPersistence().token.write("a-token"); + + expect(ephemeralPersistence().token.read()).toBeUndefined(); + }); +}); diff --git a/packages/targets/graph-web/src/ports.ts b/packages/targets/graph-web/src/ports.ts index 04dcc7f..ac7a059 100644 --- a/packages/targets/graph-web/src/ports.ts +++ b/packages/targets/graph-web/src/ports.ts @@ -59,6 +59,8 @@ export interface DownloadFile { * Everything the app reads or writes outside React, injected at the composition root (`main.tsx`) so every screen runs against fakes in tests and against the browser in the app. */ export interface AppPorts { + /** Whether the ports below keep anything on this device (`device`), or only in the page's memory (`ephemeral`), as on an origin shared with other sites. */ + readonly persistence: "device" | "ephemeral"; readonly store: GraphStoreClient; /** The sources the app can read, given the token to read GitHub with. */ readonly sources: (token: string) => SourceLookup; diff --git a/packages/targets/graph-web/src/test-support/ports.ts b/packages/targets/graph-web/src/test-support/ports.ts index f7db8fd..2599dff 100644 --- a/packages/targets/graph-web/src/test-support/ports.ts +++ b/packages/targets/graph-web/src/test-support/ports.ts @@ -273,6 +273,8 @@ export interface TestPortOptions { /** The GitHub pager; the fake source over `fakeScopes` by default. */ readonly pager?: GraphSourcePager; readonly records?: readonly SyncRecord[]; + /** `device` by default. */ + readonly persistence?: AppPorts["persistence"]; } /** @@ -304,6 +306,7 @@ export const testPorts = (options: TestPortOptions = {}): TestPorts => { }); const ports: AppPorts = { + persistence: options.persistence ?? "device", store: createRouterClient(createGraphStoreRouter(createFakeGraphStore())), sources: (used) => (prefix) => { tokensUsed.push(used); diff --git a/packages/targets/graph-web/src/token-panel.tsx b/packages/targets/graph-web/src/token-panel.tsx index 511011e..dab29d6 100644 --- a/packages/targets/graph-web/src/token-panel.tsx +++ b/packages/targets/graph-web/src/token-panel.tsx @@ -14,6 +14,8 @@ import { export interface TokenPanelProps { /** Whether a token is kept on this device. The token itself is never passed to the screen. */ readonly hasToken: boolean; + /** Whether a saved token outlives the page. When it does not, the panel says so and asks for the token again each visit. */ + readonly persistent: boolean; readonly onSave: (token: string) => void; readonly onForget: () => void; } @@ -24,7 +26,7 @@ export interface TokenPanelProps { * @returns The panel. */ export const TokenPanel = (props: TokenPanelProps): ReactElement => { - const { hasToken, onSave, onForget } = props; + const { hasToken, persistent, onSave, onForget } = props; const [draft, setDraft] = useState(""); const headingId = useId(); const inputId = useId(); @@ -66,7 +68,9 @@ export const TokenPanel = (props: TokenPanelProps): ReactElement => {

- The token is kept in this browser's storage on this device only. + {persistent + ? "The token is kept in this browser's storage on this device only. " + : "The token is held in this page's memory only and is gone when the page is closed or reloaded. "} It is sent to api.github.com and nowhere else, and never shown again once saved.

@@ -88,7 +92,7 @@ export const TokenPanel = (props: TokenPanelProps): ReactElement => { }} /> {hasToken ? ( - {ports.persistence === "ephemeral" ? : null} {online ? null : } {updateAvailable ? ( @@ -186,7 +184,6 @@ export const App = (props: AppProps): ReactElement => { {tokenOpen || !hasToken ? ( { ports.token.write(token); setHasToken(true); diff --git a/packages/targets/graph-web/src/app.unit.test.tsx b/packages/targets/graph-web/src/app.unit.test.tsx index 25de81f..e167b16 100644 --- a/packages/targets/graph-web/src/app.unit.test.tsx +++ b/packages/targets/graph-web/src/app.unit.test.tsx @@ -53,37 +53,6 @@ const cardOf = async (id: string): Promise => return card; }); -describe("an ephemeral copy", () => { - test("says at the top that nothing is kept between visits, without a way to dismiss it, and takes the token for the visit only", () => { - const ports = testPorts({ persistence: "ephemeral" }); - renderApp(ports); - - const notice = document.querySelector('[data-notice="ephemeral"]'); - expect(notice?.textContent).toContain("keeps nothing between visits"); - expect(notice?.textContent).toContain("need an address of its own"); - expect(notice?.querySelector("button")).toBeNull(); - const panel = screen.getByRole("region", { name: "GitHub token" }); - expect(panel.textContent).toContain("held in this page's memory only"); - expect( - within(panel).queryByRole("button", { name: "Save on this device" }), - ).toBeNull(); - fireEvent.change(within(panel).getByLabelText("Token"), { - target: { value: "secret-token" }, - }); - fireEvent.click( - within(panel).getByRole("button", { name: "Use for this visit" }), - ); - - expect(document.querySelector('[data-notice="ephemeral"]')).not.toBeNull(); - }); - - test("is not announced on a device that keeps its data", () => { - renderApp(testPorts()); - - expect(document.querySelector('[data-notice="ephemeral"]')).toBeNull(); - }); -}); - describe("the token screen", () => { test("is shown until a token is saved, says what the token needs and where it goes, and never shows it back", () => { const ports = testPorts(); diff --git a/packages/targets/graph-web/src/main.tsx b/packages/targets/graph-web/src/main.tsx index 53ae8fd..ad5e45e 100644 --- a/packages/targets/graph-web/src/main.tsx +++ b/packages/targets/graph-web/src/main.tsx @@ -21,11 +21,6 @@ import { serviceWorkerUpdates, systemPrefersDark, } from "./browser.ts"; -import { - ephemeralPersistence, - persistenceAllowed, - type PersistencePorts, -} from "./persistence.ts"; import type { AppPorts } from "./ports.ts"; // The composition root: the only module that knows which store, sources and browser APIs the app runs on. Every screen reaches them through `AppPorts`. @@ -35,26 +30,13 @@ if (root === null) { throw new Error("index.html must contain a #root element to mount into"); } -/** - * What outlives the page, on an origin of its own: the store in a dedicated worker on SQLite wasm over the origin private file system, reached over the store contract's oRPC client; the token, sync records and theme preference in local storage; and the service worker. - * @returns The ports. - */ -const devicePersistence = (): PersistencePorts => ({ - store: connectGraphStore( - new Worker(new URL("store.worker.ts", import.meta.url), { type: "module" }), - ), - token: localTokenStore(window.localStorage), - syncRecords: localSyncRecords(window.localStorage), - themePreference: localThemePreference(window.localStorage), - updates: serviceWorkerUpdates(registerSW), -}); - -// Whether anything outlives the page is decided once, here, from the origin: on a shared github.io origin the store, the token, the settings and the service worker are all in memory or absent, so the worker and `registerSW` are never started. -const persistent = persistenceAllowed(window.location); +/** The store, in a dedicated worker on SQLite wasm over the origin private file system, reached over the store contract's oRPC client. */ +const store = connectGraphStore( + new Worker(new URL("store.worker.ts", import.meta.url), { type: "module" }), +); const ports: AppPorts = { - persistence: persistent ? "device" : "ephemeral", - ...(persistent ? devicePersistence() : ephemeralPersistence()), + store, // The GitHub source runs in the page, called through the source contract's in-process oRPC client. The token goes only into the adapter's Authorization header for requests to GitHub's API, and the fetch it calls reaches nothing else. sources: (token) => (prefix) => prefix === githubSource @@ -67,9 +49,13 @@ const ports: AppPorts = { ), ) : undefined, + token: localTokenStore(window.localStorage), + syncRecords: localSyncRecords(window.localStorage), + themePreference: localThemePreference(window.localStorage), address: browserAddressBar(window), online: browserOnline(window), prefersDark: systemPrefersDark(window), + updates: serviceWorkerUpdates(registerSW), download: browserDownload(document), }; diff --git a/packages/targets/graph-web/src/notices.tsx b/packages/targets/graph-web/src/notices.tsx index 2e4b691..b5c6040 100644 --- a/packages/targets/graph-web/src/notices.tsx +++ b/packages/targets/graph-web/src/notices.tsx @@ -14,20 +14,6 @@ export const OfflineNotice = (): ReactElement => (

); -/** - * Says this copy of the app keeps nothing between visits, because the origin it is served from is shared with other sites. It has no dismiss control: what it says holds for the whole visit. - * @returns The notice. - */ -export const EphemeralNotice = (): ReactElement => ( -

- This copy keeps nothing between visits: its address is shared with other - sites, which could read anything it saved. The token, the synced issues and - your settings are held in memory and are gone when you close or reload the - page. Installing the app and using it offline need an address of its own, - for example running it locally from the repository. -

-); - export interface UpdateNoticeProps { readonly onReload: () => void; } diff --git a/packages/targets/graph-web/src/persistence.ts b/packages/targets/graph-web/src/persistence.ts deleted file mode 100644 index d961adf..0000000 --- a/packages/targets/graph-web/src/persistence.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - createFakeGraphStore, - createGraphStoreRouter, -} from "@exadev/process-graph-store-contract"; -import { createRouterClient } from "@orpc/server"; -import type { - AppPorts, - AppUpdates, - ThemePreference, - ThemePreferenceStore, - TokenStore, - SyncRecords, -} from "./ports.ts"; -import type { SyncRecord } from "./sync-record.ts"; - -/** The suffix of the hostnames GitHub Pages serves every account's and organisation's sites from. */ -const sharedPagesSuffix = ".github.io"; - -/** - * Whether the app may keep anything on the device for the origin it runs on. Every page under one `github.io` hostname shares an origin, and URL paths do not partition `localStorage`, IndexedDB or the origin private file system, so any other page there could read a saved token and the synced issues. On such an origin the app keeps nothing. - * @param location - The page's location, of which only the hostname is read. - * @returns False on a shared GitHub Pages origin, true anywhere else (localhost, an origin of one's own). - */ -export const persistenceAllowed = ( - location: Readonly>, -): boolean => !location.hostname.toLowerCase().endsWith(sharedPagesSuffix); - -/** The ports that decide what outlives the page, as the ephemeral mode supplies them. */ -export type PersistencePorts = Pick< - AppPorts, - "store" | "token" | "syncRecords" | "themePreference" | "updates" ->; - -/** - * Ports that hold everything in this page's memory and touch no storage: the token and the theme preference are variables, sync records a map, the store the store contract's in-memory fake, and there is no service worker and so never an update. All of it is gone when the page is. - * @returns The ports. - */ -export const ephemeralPersistence = (): PersistencePorts => { - let token: string | undefined; - let preference: ThemePreference = "system"; - const records = new Map(); - - const tokens: TokenStore = { - read: () => token, - write: (next) => { - token = next; - }, - clear: () => { - token = undefined; - }, - }; - const syncRecords: SyncRecords = { - read: (modelId) => records.get(modelId), - write: (record) => { - records.set(record.modelId, record); - }, - }; - const themePreference: ThemePreferenceStore = { - read: () => preference, - write: (next) => { - preference = next; - }, - }; - const updates: AppUpdates = { - available: { get: () => false, subscribe: () => () => undefined }, - apply: () => undefined, - }; - - return { - store: createRouterClient(createGraphStoreRouter(createFakeGraphStore())), - token: tokens, - syncRecords, - themePreference, - updates, - }; -}; diff --git a/packages/targets/graph-web/src/persistence.unit.test.ts b/packages/targets/graph-web/src/persistence.unit.test.ts deleted file mode 100644 index 2c9843a..0000000 --- a/packages/targets/graph-web/src/persistence.unit.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; -import { ephemeralPersistence, persistenceAllowed } from "./persistence.ts"; - -/** The navigator properties a test replaced, removed again afterwards. */ -const replacedOnNavigator = ["storage", "serviceWorker"] as const; - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - for (const property of replacedOnNavigator) { - Reflect.deleteProperty(window.navigator, property); - } -}); - -describe("the persistence policy", () => { - test.each(["exadev.github.io", "someone.github.io", "Someone.GitHub.io"])( - "forbids persisting on the shared origin %s", - (hostname) => { - expect(persistenceAllowed({ hostname })).toBe(false); - }, - ); - - test.each([ - "localhost", - "127.0.0.1", - "graph.example.com", - "github.io.example.com", - "notgithub.io", - ])("allows persisting on %s", (hostname) => { - expect(persistenceAllowed({ hostname })).toBe(true); - }); -}); - -/** A stand-in for a browser storage API that fails the test on any use. */ -const forbidden = (name: string): object => - new Proxy( - {}, - { - get: (_target, property) => { - throw new Error(`${name}.${String(property)} was used`); - }, - }, - ); - -describe("the ephemeral ports", () => { - test("keep the token, the settings and the sync records in memory and touch no browser storage", async () => { - const storageCalls = [ - vi.spyOn(Storage.prototype, "getItem"), - vi.spyOn(Storage.prototype, "setItem"), - vi.spyOn(Storage.prototype, "removeItem"), - vi.spyOn(Storage.prototype, "clear"), - ]; - vi.stubGlobal("indexedDB", forbidden("indexedDB")); - vi.stubGlobal("caches", forbidden("caches")); - for (const property of replacedOnNavigator) { - Object.defineProperty(window.navigator, property, { - configurable: true, - value: forbidden(`navigator.${property}`), - }); - } - - const ports = ephemeralPersistence(); - - expect(ports.token.read()).toBeUndefined(); - ports.token.write("a-token"); - expect(ports.token.read()).toBe("a-token"); - ports.token.clear(); - expect(ports.token.read()).toBeUndefined(); - - expect(ports.themePreference.read()).toBe("system"); - ports.themePreference.write("dark"); - expect(ports.themePreference.read()).toBe("dark"); - - const record = { modelId: "github:acme", gaps: [] }; - ports.syncRecords.write(record); - expect(ports.syncRecords.read("github:acme")).toBe(record); - expect(ports.syncRecords.read("github:other")).toBeUndefined(); - - expect(ports.updates.available.get()).toBe(false); - expect(() => { - ports.updates.apply(); - }).not.toThrow(); - - // The in-memory store answers, here with its own "no such model" for a model nothing has synced. - await expect( - ports.store.v1.query.ancestors({ - modelId: "github:acme", - element: "github:acme/web#1", - }), - ).rejects.toMatchObject({ code: "NOT_FOUND" }); - - for (const call of storageCalls) { - expect(call).not.toHaveBeenCalled(); - } - expect(window.localStorage).toHaveLength(0); - expect(window.sessionStorage).toHaveLength(0); - }); - - test("hold nothing between two instances", () => { - ephemeralPersistence().token.write("a-token"); - - expect(ephemeralPersistence().token.read()).toBeUndefined(); - }); -}); diff --git a/packages/targets/graph-web/src/ports.ts b/packages/targets/graph-web/src/ports.ts index ac7a059..04dcc7f 100644 --- a/packages/targets/graph-web/src/ports.ts +++ b/packages/targets/graph-web/src/ports.ts @@ -59,8 +59,6 @@ export interface DownloadFile { * Everything the app reads or writes outside React, injected at the composition root (`main.tsx`) so every screen runs against fakes in tests and against the browser in the app. */ export interface AppPorts { - /** Whether the ports below keep anything on this device (`device`), or only in the page's memory (`ephemeral`), as on an origin shared with other sites. */ - readonly persistence: "device" | "ephemeral"; readonly store: GraphStoreClient; /** The sources the app can read, given the token to read GitHub with. */ readonly sources: (token: string) => SourceLookup; diff --git a/packages/targets/graph-web/src/test-support/ports.ts b/packages/targets/graph-web/src/test-support/ports.ts index 2599dff..f7db8fd 100644 --- a/packages/targets/graph-web/src/test-support/ports.ts +++ b/packages/targets/graph-web/src/test-support/ports.ts @@ -273,8 +273,6 @@ export interface TestPortOptions { /** The GitHub pager; the fake source over `fakeScopes` by default. */ readonly pager?: GraphSourcePager; readonly records?: readonly SyncRecord[]; - /** `device` by default. */ - readonly persistence?: AppPorts["persistence"]; } /** @@ -306,7 +304,6 @@ export const testPorts = (options: TestPortOptions = {}): TestPorts => { }); const ports: AppPorts = { - persistence: options.persistence ?? "device", store: createRouterClient(createGraphStoreRouter(createFakeGraphStore())), sources: (used) => (prefix) => { tokensUsed.push(used); diff --git a/packages/targets/graph-web/src/token-panel.tsx b/packages/targets/graph-web/src/token-panel.tsx index dab29d6..511011e 100644 --- a/packages/targets/graph-web/src/token-panel.tsx +++ b/packages/targets/graph-web/src/token-panel.tsx @@ -14,8 +14,6 @@ import { export interface TokenPanelProps { /** Whether a token is kept on this device. The token itself is never passed to the screen. */ readonly hasToken: boolean; - /** Whether a saved token outlives the page. When it does not, the panel says so and asks for the token again each visit. */ - readonly persistent: boolean; readonly onSave: (token: string) => void; readonly onForget: () => void; } @@ -26,7 +24,7 @@ export interface TokenPanelProps { * @returns The panel. */ export const TokenPanel = (props: TokenPanelProps): ReactElement => { - const { hasToken, persistent, onSave, onForget } = props; + const { hasToken, onSave, onForget } = props; const [draft, setDraft] = useState(""); const headingId = useId(); const inputId = useId(); @@ -68,9 +66,7 @@ export const TokenPanel = (props: TokenPanelProps): ReactElement => {

- {persistent - ? "The token is kept in this browser's storage on this device only. " - : "The token is held in this page's memory only and is gone when the page is closed or reloaded. "} + The token is kept in this browser's storage on this device only. It is sent to api.github.com and nowhere else, and never shown again once saved.

@@ -92,7 +88,7 @@ export const TokenPanel = (props: TokenPanelProps): ReactElement => { }} /> {hasToken ? (