this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Playwright web e2e scaffolding and OAuth test-mode overrides

Web app: add login_hint to PAR (spec-compliant), make handle
resolution and PLC directory URLs configurable via VITE_RESOLVE_API
and VITE_PLC_DIRECTORY_URL for test environments.

e2e-tests: add Playwright with global setup (fake-pds OAuth +
Vite dev server lifecycle), custom fixtures, and smoke tests.
Bump fake-pds to 0.3.0 for the new authorize endpoint. [CL-360]

+207 -9
+3
.gitignore
··· 16 16 driver-key.pub 17 17 .vscode/ 18 18 .vite/ 19 + e2e-tests/.e2e-state.json 20 + e2e-tests/test-results/ 21 + e2e-tests/playwright-report/ 19 22 20 23 # === Crosslink managed (do not edit between markers) === 21 24 # .crosslink/ — machine-local state (never commit)
+1 -1
CHANGELOG.md
··· 7 7 ## [Unreleased] 8 8 9 9 ### Added 10 - - Add e2e test suite for CLI against fake-pds (#358) 10 + - Add e2e test suite for CLI against fake-pds [#358](https://issues.opake.app/issues/358.html) 11 11 - Add DID document resolution to fake-pds and CLI PLC directory override [#359](https://issues.opake.app/issues/359.html) 12 12 - Add fake-pds: in-memory AT Protocol PDS for integration testing [#349](https://issues.opake.app/issues/349.html) 13 13 - Move appview URL from local config to PDS-synced account config [#346](https://issues.opake.app/issues/346.html)
+14 -3
e2e-tests/bun.lock
··· 5 5 "": { 6 6 "name": "opake-e2e-tests", 7 7 "devDependencies": { 8 + "@playwright/test": "^1.58.2", 8 9 "@types/node": "^22", 9 - "fake-pds": "^0.2.0", 10 + "fake-pds": "^0.3.0", 10 11 "typescript": "^5.7", 11 12 "vitest": "^3.0", 12 13 }, ··· 66 67 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], 67 68 68 69 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 70 + 71 + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], 69 72 70 73 "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], 71 74 ··· 159 162 160 163 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], 161 164 162 - "fake-pds": ["fake-pds@0.2.0", "", {}, "sha512-Yi1BSuchQhA8q5AIVsWiGbnr7MzQvU1/576GvZyPOBDyV1byIWva0N55ZLm223ZRHsxqQCH3kZmXjHVKiXrZqQ=="], 165 + "fake-pds": ["fake-pds@0.3.0", "", {}, "sha512-UUZJd78CkCPmuwkkNj7fdtK1kfQcngv+yByyjVocYSyamNXOlonL2hgLEdYR1rNGQY/DcTuNypMplawQM9XC1Q=="], 163 166 164 167 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 165 168 166 - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 169 + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], 167 170 168 171 "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], 169 172 ··· 183 186 184 187 "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], 185 188 189 + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], 190 + 191 + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], 192 + 186 193 "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], 187 194 188 195 "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], ··· 220 227 "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], 221 228 222 229 "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 230 + 231 + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 232 + 233 + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 223 234 } 224 235 }
+91
e2e-tests/global-setup.ts
··· 1 + // Global setup for Playwright web e2e tests. 2 + // 3 + // Starts a fake-pds instance (OAuth mode) and a Vite dev server pointing at it. 4 + // Writes both URLs to a state file so test fixtures can read them. 5 + 6 + import { createFakePds, type FakePds } from "fake-pds"; 7 + import { spawn, type ChildProcess } from "node:child_process"; 8 + import { writeFileSync, unlinkSync, mkdirSync } from "node:fs"; 9 + import path from "node:path"; 10 + 11 + export const TEST_ACCOUNTS = [ 12 + { did: "did:plc:alice", handle: "alice.test" }, 13 + { did: "did:plc:bob", handle: "bob.test", password: "bobsecret" }, 14 + ] as const; 15 + 16 + const STATE_FILE = path.join(import.meta.dirname, ".e2e-state.json"); 17 + 18 + function waitForViteReady(proc: ChildProcess, timeoutMs = 30_000): Promise<string> { 19 + return new Promise((resolve, reject) => { 20 + const timeout = setTimeout(() => { 21 + reject(new Error(`Vite dev server did not start within ${timeoutMs}ms`)); 22 + }, timeoutMs); 23 + 24 + const onData = (chunk: Buffer) => { 25 + const text = chunk.toString(); 26 + // Vite prints "Local: http://localhost:XXXX/" when ready 27 + const match = /https?:\/\/localhost:\d+/.exec(text); 28 + if (match) { 29 + clearTimeout(timeout); 30 + proc.stdout?.off("data", onData); 31 + resolve(match[0]); 32 + } 33 + }; 34 + 35 + proc.stdout?.on("data", onData); 36 + proc.stderr?.on("data", (chunk: Buffer) => { 37 + // Surface Vite errors for debugging 38 + process.stderr.write(chunk); 39 + }); 40 + 41 + proc.on("error", (err) => { 42 + clearTimeout(timeout); 43 + reject(err); 44 + }); 45 + 46 + proc.on("exit", (code) => { 47 + clearTimeout(timeout); 48 + if (code !== null && code !== 0) { 49 + reject(new Error(`Vite dev server exited with code ${code}`)); 50 + } 51 + }); 52 + }); 53 + } 54 + 55 + export default async function globalSetup(): Promise<() => Promise<void>> { 56 + // 1. Start fake-pds with OAuth enabled 57 + const pds: FakePds = await createFakePds({ 58 + accounts: [...TEST_ACCOUNTS], 59 + auth: "oauth", 60 + }); 61 + 62 + // 2. Start Vite dev server with env vars pointing at fake-pds 63 + const webRoot = path.resolve(import.meta.dirname, "../web"); 64 + const vite: ChildProcess = spawn("bun", ["run", "dev"], { 65 + cwd: webRoot, 66 + env: { 67 + ...process.env, 68 + VITE_RESOLVE_API: pds.url, 69 + VITE_PLC_DIRECTORY_URL: pds.url, 70 + VITE_APPVIEW_URL: "", 71 + }, 72 + stdio: ["pipe", "pipe", "pipe"], 73 + }); 74 + 75 + const webUrl = await waitForViteReady(vite); 76 + 77 + // 3. Write state for test fixtures 78 + mkdirSync(path.dirname(STATE_FILE), { recursive: true }); 79 + writeFileSync(STATE_FILE, JSON.stringify({ pdsUrl: pds.url, webUrl })); 80 + 81 + // 4. Teardown 82 + return async () => { 83 + vite.kill("SIGTERM"); 84 + await pds.close(); 85 + try { 86 + unlinkSync(STATE_FILE); 87 + } catch { 88 + // already cleaned up 89 + } 90 + }; 91 + }
+47
e2e-tests/helpers/web-fixture.ts
··· 1 + // Playwright test fixtures for web e2e tests. 2 + // 3 + // Provides pdsUrl, webUrl, and resetPds to all web tests. 4 + // See global-setup.ts for server lifecycle. 5 + 6 + import { test as base, expect } from "@playwright/test"; 7 + import { readFileSync } from "node:fs"; 8 + import path from "node:path"; 9 + 10 + interface E2eState { 11 + readonly pdsUrl: string; 12 + readonly webUrl: string; 13 + } 14 + 15 + function loadState(): E2eState { 16 + const statePath = path.join(import.meta.dirname, "../.e2e-state.json"); 17 + return JSON.parse(readFileSync(statePath, "utf-8")) as E2eState; 18 + } 19 + 20 + interface WebFixtures { 21 + pdsUrl: string; 22 + webUrl: string; 23 + resetPds: () => Promise<void>; 24 + } 25 + 26 + export const test = base.extend<WebFixtures>({ 27 + pdsUrl: async ({}, use) => { 28 + const { pdsUrl } = loadState(); 29 + await use(pdsUrl); 30 + }, 31 + 32 + webUrl: async ({}, use) => { 33 + const { webUrl } = loadState(); 34 + await use(webUrl); 35 + }, 36 + 37 + resetPds: async ({ pdsUrl }, use) => { 38 + const reset = async () => { 39 + await fetch(`${pdsUrl}/_test/reset`, { method: "POST" }); 40 + }; 41 + // Reset before the test to ensure clean state 42 + await reset(); 43 + await use(reset); 44 + }, 45 + }); 46 + 47 + export { expect };
+6 -2
e2e-tests/package.json
··· 4 4 "type": "module", 5 5 "scripts": { 6 6 "test": "vitest run", 7 - "test:watch": "vitest" 7 + "test:watch": "vitest", 8 + "test:web": "playwright test", 9 + "test:web:headed": "playwright test --headed", 10 + "test:web:ui": "playwright test --ui" 8 11 }, 9 12 "devDependencies": { 13 + "@playwright/test": "^1.58.2", 10 14 "@types/node": "^22", 11 - "fake-pds": "^0.2.0", 15 + "fake-pds": "^0.3.0", 12 16 "typescript": "^5.7", 13 17 "vitest": "^3.0" 14 18 }
+13
e2e-tests/playwright.config.ts
··· 1 + import { defineConfig } from "@playwright/test"; 2 + 3 + export default defineConfig({ 4 + testDir: "tests/web", 5 + timeout: 60_000, 6 + globalSetup: "./global-setup.ts", 7 + use: { 8 + headless: true, 9 + screenshot: "only-on-failure", 10 + trace: "retain-on-failure", 11 + }, 12 + projects: [{ name: "chromium", use: { browserName: "chromium" } }], 13 + });
+15
e2e-tests/tests/web/smoke.test.ts
··· 1 + // Smoke test: verify the web app loads against fake-pds. 2 + 3 + import { test, expect } from "../../helpers/web-fixture.js"; 4 + 5 + test("app loads and shows login page", async ({ page, webUrl }) => { 6 + await page.goto(`${webUrl}/devices/login`); 7 + await expect(page.locator("body")).toBeVisible(); 8 + }); 9 + 10 + test("fake-pds is reachable", async ({ pdsUrl }) => { 11 + const res = await fetch(`${pdsUrl}/.well-known/oauth-authorization-server`); 12 + expect(res.status).toBe(200); 13 + const body = (await res.json()) as { issuer: string }; 14 + expect(body.issuer).toBe(pdsUrl); 15 + });
+1
e2e-tests/vitest.config.ts
··· 4 4 test: { 5 5 testTimeout: 30_000, 6 6 hookTimeout: 30_000, 7 + exclude: ["tests/web/**", "node_modules/**"], 7 8 }, 8 9 });
+11 -2
web/src/lib/did.ts
··· 2 2 // 3 3 // URL construction and document parsing are delegated to opake-core via WASM. 4 4 // This module handles the HTTP fetch (which WASM can't do). 5 + // 6 + // VITE_PLC_DIRECTORY_URL overrides the PLC directory base URL for testing 7 + // (e.g. pointing at a fake-pds instance that serves DID documents). 5 8 6 9 import { getCryptoWorker } from "@/lib/worker"; 7 10 11 + const PLC_DIRECTORY_OVERRIDE = import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined; 12 + 8 13 /** Fetch and parse a DID document, returning the PDS URL. */ 9 14 export async function pdsUrlFromDid(did: string): Promise<string> { 10 15 const worker = getCryptoWorker(); 11 - const url = await worker.didDocumentUrl(did); 16 + const url = PLC_DIRECTORY_OVERRIDE 17 + ? `${PLC_DIRECTORY_OVERRIDE}/${did}` 18 + : await worker.didDocumentUrl(did); 12 19 13 20 const response = await fetch(url); 14 21 if (!response.ok) { ··· 22 29 /** Fetch a DID document and extract the handle from `alsoKnownAs`. */ 23 30 export async function handleFromDid(did: string): Promise<string | undefined> { 24 31 const worker = getCryptoWorker(); 25 - const url = await worker.didDocumentUrl(did); 32 + const url = PLC_DIRECTORY_OVERRIDE 33 + ? `${PLC_DIRECTORY_OVERRIDE}/${did}` 34 + : await worker.didDocumentUrl(did); 26 35 27 36 const response = await fetch(url); 28 37 if (!response.ok) return undefined;
+4 -1
web/src/lib/oauth.ts
··· 49 49 } 50 50 51 51 const PENDING_STATE_KEY = "opake:oauth_pending"; 52 - const BSKY_PUBLIC_API = "https://public.api.bsky.app"; 52 + const BSKY_PUBLIC_API = 53 + (import.meta.env.VITE_RESOLVE_API as string | undefined) ?? "https://public.api.bsky.app"; 53 54 54 55 // --------------------------------------------------------------------------- 55 56 // Handle → PDS resolution ··· 191 192 dpopKey: DpopKeyPair, 192 193 dpopNonce: string | null, 193 194 worker: CryptoWorker, 195 + loginHint?: string, 194 196 ): Promise<{ requestUri: string; expiresIn: number; dpopNonce: string | null }> { 195 197 const body = new URLSearchParams({ 196 198 client_id: clientId, ··· 200 202 state, 201 203 code_challenge: pkceChallenge, 202 204 code_challenge_method: "S256", 205 + ...(loginHint ? { login_hint: loginHint } : {}), 203 206 }); 204 207 205 208 const { response, dpopNonce: nonce } = await fetchWithDpop(
+1
web/src/stores/auth.ts
··· 433 433 dpopKey, 434 434 null, 435 435 worker, 436 + handle, 436 437 ); 437 438 438 439 savePendingState({