test(tailshare): cover credential lifecycle in browsers

Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
2026-08-29 00:31:50 +00:00
co-authored by Codex
parent 14bb372490
commit bad8d7c219
9 changed files with 405 additions and 5 deletions
+1 -1
View File
@@ -206,4 +206,4 @@ jobs:
run: npx playwright install --with-deps chromium firefox run: npx playwright install --with-deps chromium firefox
- name: Run browser tests - name: Run browser tests
run: npm run test:browser run: npm run test:browser -- --only --concurrency=1
+1
View File
@@ -10583,6 +10583,7 @@
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.4", "@types/react-dom": "^19.1.4",
"@webnet/browser-test-utils": "*",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.3", "html-webpack-plugin": "^5.6.3",
+14 -1
View File
@@ -2,7 +2,7 @@
Shared Playwright utilities for browser integration tests. Shared Playwright utilities for browser integration tests.
This is a private workspace, not a published library: it is used only by this repo's own `node:test` suites that need a real browser. `forBrowsers` wraps a test body in a `node:test` `suite` per configured browser (Chromium and Firefox), launching a headless `Browser` in `before` and closing it in `after`, and hands the test a `BrowserTestContext` with `newPage()` and `serve(dir)`. `newPage()` injects an `__name` shim into the page, working around `tsx`'s esbuild `keepNames` output losing function names when `page.evaluate()` serializes callbacks via `toString()`. `serve(dir)` starts a local static file server (`TestServer`) rooted at `dir`, rejecting paths that escape it. This is a private workspace, not a published library: it is used only by this repo's own `node:test` suites that need a real browser. `forBrowsers` wraps a test body in a `node:test` `suite` per configured browser (Chromium and Firefox), launching a headless `Browser` in `before` and closing it in `after`, and hands the test a `BrowserTestContext` with `newPage()`, `newContext()`, and `serve(dir)`. Both browser helpers inject an `__name` shim into pages, working around `tsx`'s esbuild `keepNames` output losing function names when `page.evaluate()` serializes callbacks via `toString()`. Use `newContext()` when a test needs multiple pages that share browser-context state. `serve(dir)` starts a local static file server (`TestServer`) rooted at `dir`, rejecting paths that escape it.
## Usage ## Usage
@@ -19,6 +19,19 @@ forBrowsers(({ browserName, newPage, serve }) => {
}) })
``` ```
Use `newContext()` for multiple pages in one browser context:
```ts
forBrowsers(({ newContext }) => {
test("shares context state", async () => {
const context = await newContext()
const firstPage = await context.newPage()
const secondPage = await context.newPage()
await context.close()
})
})
```
## See also ## See also
- [`@webnet/test-app`](../test-app) — one of the apps commonly served under test via `serve()` - [`@webnet/test-app`](../test-app) — one of the apps commonly served under test via `serve()`
+16 -1
View File
@@ -3,7 +3,7 @@ import { createServer } from "node:http"
import { createReadStream, statSync } from "node:fs" import { createReadStream, statSync } from "node:fs"
import { extname, resolve as resolvePath, relative } from "node:path" import { extname, resolve as resolvePath, relative } from "node:path"
import { chromium, firefox } from "playwright" import { chromium, firefox } from "playwright"
import type { Browser, Page, BrowserType } from "playwright" import type { Browser, BrowserContext, Page, BrowserType } from "playwright"
export type { Page } export type { Page }
@@ -15,6 +15,7 @@ export interface TestServer {
export interface BrowserTestContext { export interface BrowserTestContext {
readonly browserName: string readonly browserName: string
newPage(): Promise<Page> newPage(): Promise<Page>
newContext(): Promise<BrowserContext>
serve(dir: string): Promise<TestServer> serve(dir: string): Promise<TestServer>
} }
@@ -46,6 +47,20 @@ export function forBrowsers(fn: (ctx: BrowserTestContext) => void): void {
fn({ fn({
browserName: name, browserName: name,
newContext: async () => {
const context = await browser.newContext()
// tsx uses esbuild keepNames:true, injecting __name at module scope; page.evaluate() serializes callbacks via .toString(), losing the helper.
await context.addInitScript(() => {
;(globalThis as unknown as Record<string, unknown>).__name = (
target: unknown,
value: string,
) => {
Object.defineProperty(target, "name", { value, configurable: true })
return target
}
})
return context
},
newPage: async () => { newPage: async () => {
const page = await browser.newPage() const page = await browser.newPage()
// tsx uses esbuild keepNames:true, injecting __name at module scope; page.evaluate() serializes callbacks via .toString(), losing the helper. // tsx uses esbuild keepNames:true, injecting __name at module scope; page.evaluate() serializes callbacks via .toString(), losing the helper.
+2
View File
@@ -6,6 +6,7 @@
"dev": "webpack serve --mode development", "dev": "webpack serve --mode development",
"build": "NODE_ENV=production webpack --mode production", "build": "NODE_ENV=production webpack --mode production",
"test": "tsx --test --test-force-exit 'src/**/*.test.ts'", "test": "tsx --test --test-force-exit 'src/**/*.test.ts'",
"test:browser": "tsx --test --test-timeout=120000 'src/**/*.browser.ts'",
"typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.test.json --noEmit" "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.test.json --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -38,6 +39,7 @@
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.4", "@types/react-dom": "^19.1.4",
"@webnet/browser-test-utils": "*",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.3", "html-webpack-plugin": "^5.6.3",
+346
View File
@@ -0,0 +1,346 @@
import test from "node:test"
import assert from "node:assert/strict"
import { existsSync } from "node:fs"
import { join } from "node:path"
import {
forBrowsers,
type BrowserTestContext,
type Page,
type TestServer,
} from "@webnet/browser-test-utils"
const distDir = join(process.cwd(), "dist")
const DIST_BUILT = existsSync(join(distDir, "index.html"))
const AUTH_KEY_A = "tskey-auth-k-browser-first-secret"
const AUTH_KEY_B = "tskey-auth-k-browser-second-secret"
type TestIpn = {
connectionMode?: string
store?: {
dispatch(action: { type: string; payload: string }): void
}
}
type WasmGate = {
calls: number
ready: boolean
reject?: () => void
}
async function openTailscale(page: Page, url: string): Promise<void> {
await page.goto(`${url}/`)
await page.evaluate(() => {
history.pushState({}, "", "/tailscale")
dispatchEvent(new PopStateEvent("popstate"))
})
await page.getByRole("heading", { name: "Tailscale netstack" }).waitFor()
}
async function openConfig(page: Page): Promise<void> {
await page.getByRole("button", { name: "Tailscale config" }).click()
await page.getByRole("dialog", { name: "Tailscale config" }).waitFor()
}
async function closeConfig(page: Page): Promise<void> {
await page.keyboard.press("Escape")
await page.getByRole("dialog", { name: "Tailscale config" }).waitFor({ state: "hidden" })
}
async function setInitialConfig(page: Page, config: Record<string, unknown>): Promise<void> {
await page.addInitScript((initial) => {
if (!localStorage.getItem("tailshare:config"))
localStorage.setItem("tailshare:config", JSON.stringify(initial))
}, config)
}
async function storedValues(page: Page): Promise<Record<string, string>> {
return page.evaluate(() => {
const values: Record<string, string> = {}
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key) values[key] = localStorage.getItem(key) ?? ""
}
return values
})
}
async function waitForConnection(page: Page, mode: "worker" | "main-thread"): Promise<void> {
await page.waitForFunction(
(expected) =>
(globalThis as typeof globalThis & { ipn?: TestIpn }).ipn?.connectionMode === expected,
mode,
)
}
async function reportRunning(page: Page): Promise<void> {
const hasStore = await page.evaluate(
() => !!(globalThis as typeof globalThis & { ipn?: TestIpn }).ipn?.store,
)
assert.equal(hasStore, true, "the selected connection path must expose its client store")
await page.evaluate(() => {
;(globalThis as typeof globalThis & { ipn?: TestIpn }).ipn!.store!.dispatch({
type: "state/setState",
payload: "Running",
})
})
}
async function withApp(
ctx: BrowserTestContext,
fn: (
context: Awaited<ReturnType<BrowserTestContext["newContext"]>>,
server: TestServer,
) => Promise<void>,
): Promise<void> {
const server = await ctx.serve(distDir)
const context = await ctx.newContext()
try {
await fn(context, server)
} finally {
await context.close()
await server.close()
}
}
if (!DIST_BUILT) {
if (process.env.CI) throw new Error("tailshare/dist must be built before browser tests in CI")
test("Tailshare browser tests", { skip: "tailshare/dist is not built" }, () => {})
} else {
forBrowsers((ctx) => {
test("reload drops a non-persisted auth key without losing preferences", async () => {
await withApp(ctx, async (context, server) => {
const page = await context.newPage()
await setInitialConfig(page, {
hostname: "browser-test-host",
useWorker: true,
fileOps: "memory",
})
await openTailscale(page, server.url)
await openConfig(page)
await page.getByLabel("Pregenerated authkey").fill(AUTH_KEY_A)
const beforeReload = await storedValues(page)
assert.equal(beforeReload["tailshare:authKey"], undefined)
assert.doesNotMatch(JSON.stringify(beforeReload), new RegExp(AUTH_KEY_A))
await openTailscale(page, server.url)
await openConfig(page)
assert.equal(
await page.getByLabel("Pregenerated authkey").getAttribute("placeholder"),
"(Manual login flow)",
)
assert.equal(await page.getByLabel("Hostname").inputValue(), "browser-test-host")
assert.doesNotMatch(JSON.stringify(await storedValues(page)), new RegExp(AUTH_KEY_A))
})
})
test("a persisted auth key survives reload until it is forgotten", async () => {
await withApp(ctx, async (context, server) => {
const page = await context.newPage()
await setInitialConfig(page, { useWorker: true, fileOps: "memory" })
await openTailscale(page, server.url)
await openConfig(page)
await page.getByLabel("Pregenerated authkey").fill(AUTH_KEY_A)
await page.getByLabel("Keep this authkey in this browser").check()
assert.equal((await storedValues(page))["tailshare:authKey"], AUTH_KEY_A)
await openTailscale(page, server.url)
await openConfig(page)
assert.equal(
await page.getByLabel("Pregenerated authkey").getAttribute("placeholder"),
"(Set, hidden)",
)
assert.equal(await page.getByLabel("Keep this authkey in this browser").isChecked(), true)
await page.getByRole("button", { name: "Forget authkey" }).click()
assert.equal((await storedValues(page))["tailshare:authKey"], undefined)
assert.equal(
await page.getByLabel("Pregenerated authkey").getAttribute("placeholder"),
"(Manual login flow)",
)
})
})
test("Running discards a non-persisted key and a keyless second tab attaches", async () => {
await withApp(ctx, async (context, server) => {
const firstPage = await context.newPage()
await setInitialConfig(firstPage, {
controlURL: "http://127.0.0.1:1",
useWorker: true,
fileOps: "memory",
})
await openTailscale(firstPage, server.url)
await openConfig(firstPage)
await firstPage.getByLabel("Pregenerated authkey").fill(AUTH_KEY_A)
await closeConfig(firstPage)
await firstPage.getByRole("button", { name: "Enable Tailscale" }).click()
await waitForConnection(firstPage, "worker")
await reportRunning(firstPage)
await openConfig(firstPage)
assert.equal(
await firstPage.getByLabel("Pregenerated authkey").getAttribute("placeholder"),
"(Manual login flow)",
)
assert.equal((await storedValues(firstPage))["tailshare:authKey"], undefined)
await closeConfig(firstPage)
await firstPage.waitForFunction(
() => JSON.parse(localStorage.getItem("tailshare:config") ?? "{}").autostart === true,
)
const secondPage = await context.newPage()
await openTailscale(secondPage, server.url)
assert.equal((await storedValues(secondPage))["tailshare:authKey"], undefined)
await waitForConnection(secondPage, "worker")
assert.equal(await secondPage.getByText("Mode:").innerText(), "Mode: worker")
})
})
test("Running retains an explicitly persisted key", async () => {
await withApp(ctx, async (context, server) => {
const page = await context.newPage()
await setInitialConfig(page, {
controlURL: "http://127.0.0.1:1",
useWorker: true,
fileOps: "memory",
})
await openTailscale(page, server.url)
await openConfig(page)
await page.getByLabel("Pregenerated authkey").fill(AUTH_KEY_A)
await page.getByLabel("Keep this authkey in this browser").check()
await closeConfig(page)
await page.getByRole("button", { name: "Enable Tailscale" }).click()
await waitForConnection(page, "worker")
await reportRunning(page)
assert.equal((await storedValues(page))["tailshare:authKey"], AUTH_KEY_A)
await openConfig(page)
assert.equal(await page.getByLabel("Keep this authkey in this browser").isChecked(), true)
assert.equal(
await page.getByLabel("Pregenerated authkey").getAttribute("placeholder"),
"(Set, hidden)",
)
})
})
test("a SharedWorker failure falls back without persisting the key", async () => {
await withApp(ctx, async (context, server) => {
await context.addInitScript(() => {
Object.defineProperty(globalThis, "SharedWorker", {
configurable: true,
value: class {
constructor() {
throw new Error("worker unavailable in browser test")
}
},
})
})
const page = await context.newPage()
await setInitialConfig(page, {
controlURL: "http://127.0.0.1:1",
useWorker: true,
fileOps: "memory",
})
await openTailscale(page, server.url)
await openConfig(page)
await page.getByLabel("Pregenerated authkey").fill(AUTH_KEY_A)
await closeConfig(page)
await page.getByRole("button", { name: "Enable Tailscale" }).click()
await waitForConnection(page, "main-thread")
assert.equal(await page.getByText("Mode:").innerText(), "Mode: main-thread")
assert.doesNotMatch(await page.locator("body").innerText(), new RegExp(AUTH_KEY_A))
assert.doesNotMatch(JSON.stringify(await storedValues(page)), new RegExp(AUTH_KEY_A))
})
})
test("worker and fallback failures redact the key from rendered diagnostics", async () => {
await withApp(ctx, async (context, server) => {
await context.addInitScript((secret) => {
Object.defineProperty(globalThis, "SharedWorker", {
configurable: true,
value: class {
constructor() {
throw new Error(`worker failed for ${secret}`)
}
},
})
WebAssembly.instantiateStreaming = async () => {
throw new Error(`main thread failed for ${secret}`)
}
}, AUTH_KEY_A)
const page = await context.newPage()
await setInitialConfig(page, { useWorker: true, fileOps: "memory" })
await openTailscale(page, server.url)
await openConfig(page)
await page.getByLabel("Pregenerated authkey").fill(AUTH_KEY_A)
await closeConfig(page)
await page.getByRole("button", { name: "Enable Tailscale" }).click()
await page.getByText("Tailscale could not start.").waitFor()
await page.getByText("Technical details").click()
const failureText = await page.locator("main").innerText()
assert.match(failureText, /\[redacted\]/)
assert.doesNotMatch(failureText, new RegExp(AUTH_KEY_A))
assert.doesNotMatch(await page.content(), new RegExp(AUTH_KEY_A))
assert.doesNotMatch(JSON.stringify(await storedValues(page)), new RegExp(AUTH_KEY_A))
assert.equal(await page.getByText("SharedWorker failure").isVisible(), true)
assert.equal(await page.getByRole("button", { name: "Retry" }).isEnabled(), true)
})
})
test("an autostart timer cannot replace a pending build's redaction key", async () => {
await withApp(ctx, async (context, server) => {
await context.addInitScript((secret) => {
const gate: WasmGate = { calls: 0, ready: false }
;(
globalThis as typeof globalThis & { __tailshareWasmGate?: WasmGate }
).__tailshareWasmGate = gate
WebAssembly.instantiateStreaming = async () => {
gate.calls++
gate.ready = true
return new Promise((_, reject) => {
gate.reject = () => reject(new Error(`initialization failed for ${secret}`))
})
}
}, AUTH_KEY_A)
const page = await context.newPage()
await setInitialConfig(page, { useWorker: false, fileOps: "memory" })
await openTailscale(page, server.url)
await openConfig(page)
await page.getByLabel("Pregenerated authkey").fill(AUTH_KEY_A)
await closeConfig(page)
await page.getByRole("button", { name: "Enable Tailscale" }).click()
await page.waitForFunction(
() =>
(globalThis as typeof globalThis & { __tailshareWasmGate?: WasmGate })
.__tailshareWasmGate?.ready === true,
)
await openConfig(page)
await page.getByLabel("Pregenerated authkey").fill(AUTH_KEY_B)
await page.getByLabel("Automatically start Tailscale with the app").check()
await closeConfig(page)
await page.waitForTimeout(1200)
await page.evaluate(() =>
(
globalThis as typeof globalThis & { __tailshareWasmGate?: WasmGate }
).__tailshareWasmGate?.reject?.(),
)
await page.getByText("Tailscale could not start.").waitFor()
const failureText = await page.locator("main").innerText()
assert.match(failureText, /\[redacted\]/)
assert.doesNotMatch(failureText, new RegExp(AUTH_KEY_A))
assert.equal(
await page.evaluate(
() =>
(globalThis as typeof globalThis & { __tailshareWasmGate?: WasmGate })
.__tailshareWasmGate?.calls,
),
1,
)
})
})
})
}
+1 -1
View File
@@ -15,5 +15,5 @@
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src"], "include": ["src"],
"exclude": ["src/**/*.test.ts"] "exclude": ["src/**/*.test.ts", "src/**/*.browser.ts"]
} }
+1 -1
View File
@@ -3,6 +3,6 @@
"compilerOptions": { "compilerOptions": {
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*.test.ts"], "include": ["src/**/*.test.ts", "src/**/*.browser.ts"],
"exclude": [] "exclude": []
} }
@@ -10,6 +10,7 @@ import {
import type { TransferHost } from "./client.js" import type { TransferHost } from "./client.js"
import type { TransferToken } from "./protocol.js" import type { TransferToken } from "./protocol.js"
import { pumpStreamToPort, portToReadableStream } from "./protocol.js" import { pumpStreamToPort, portToReadableStream } from "./protocol.js"
import { setState } from "@webnet/tsconnect-redux"
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -826,6 +827,28 @@ async function fakeConnect(script: FakeWorker): Promise<{
} }
suite("IpnWorkerClient state transfer", () => { suite("IpnWorkerClient state transfer", () => {
test("worker state actions update the client store and notify run callbacks", async () => {
const { client, workerPort } = await fakeConnect((msg, port) => {
if (msg.type === "hello")
port.postMessage({
type: "ready",
configIdentity: "test",
initialized: true,
attachedDuringInitialization: false,
})
})
const states: string[] = []
client.run({ notifyState: (state) => states.push(state) })
workerPort.postMessage({ type: "action", action: setState("Running") })
await waitFor(() => client.store.getState().state.state === "Running")
assert.equal(client.state, "Running")
assert.deepEqual(states, ["NoState", "Running"])
await client.disconnect()
workerPort.close()
})
test("disconnect releases its lock when the worker does not acknowledge", async (t) => { test("disconnect releases its lock when the worker does not acknowledge", async (t) => {
let lockReleased = false let lockReleased = false
installFakeLocks(() => { installFakeLocks(() => {