test(tailshare): cover credential lifecycle in browsers
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
@@ -206,4 +206,4 @@ jobs:
|
||||
run: npx playwright install --with-deps chromium firefox
|
||||
|
||||
- name: Run browser tests
|
||||
run: npm run test:browser
|
||||
run: npm run test:browser -- --only --concurrency=1
|
||||
|
||||
Generated
+1
@@ -10583,6 +10583,7 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.4",
|
||||
"@webnet/browser-test-utils": "*",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
- [`@webnet/test-app`](../test-app) — one of the apps commonly served under test via `serve()`
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createServer } from "node:http"
|
||||
import { createReadStream, statSync } from "node:fs"
|
||||
import { extname, resolve as resolvePath, relative } from "node:path"
|
||||
import { chromium, firefox } from "playwright"
|
||||
import type { Browser, Page, BrowserType } from "playwright"
|
||||
import type { Browser, BrowserContext, Page, BrowserType } from "playwright"
|
||||
|
||||
export type { Page }
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface TestServer {
|
||||
export interface BrowserTestContext {
|
||||
readonly browserName: string
|
||||
newPage(): Promise<Page>
|
||||
newContext(): Promise<BrowserContext>
|
||||
serve(dir: string): Promise<TestServer>
|
||||
}
|
||||
|
||||
@@ -46,6 +47,20 @@ export function forBrowsers(fn: (ctx: BrowserTestContext) => void): void {
|
||||
|
||||
fn({
|
||||
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 () => {
|
||||
const page = await browser.newPage()
|
||||
// tsx uses esbuild keepNames:true, injecting __name at module scope; page.evaluate() serializes callbacks via .toString(), losing the helper.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"dev": "webpack serve --mode development",
|
||||
"build": "NODE_ENV=production webpack --mode production",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -38,6 +39,7 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.4",
|
||||
"@webnet/browser-test-utils": "*",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -15,5 +15,5 @@
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.browser.ts"]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.test.ts"],
|
||||
"include": ["src/**/*.test.ts", "src/**/*.browser.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { TransferHost } from "./client.js"
|
||||
import type { TransferToken } from "./protocol.js"
|
||||
import { pumpStreamToPort, portToReadableStream } from "./protocol.js"
|
||||
import { setState } from "@webnet/tsconnect-redux"
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -826,6 +827,28 @@ async function fakeConnect(script: FakeWorker): Promise<{
|
||||
}
|
||||
|
||||
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) => {
|
||||
let lockReleased = false
|
||||
installFakeLocks(() => {
|
||||
|
||||
Reference in New Issue
Block a user