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
|
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
|
||||||
|
|||||||
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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()`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["src/**/*.test.ts"]
|
"exclude": ["src/**/*.test.ts", "src/**/*.browser.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user