test: restore real Web E2E against DSH alpha (#421)

* test: support alpha browser authentication

* test: cover alpha settings Remote transport

---------

Co-authored-by: Justin Skowyra <289723040+Jstn-1g@users.noreply.github.com>
This commit is contained in:
Justin Skowyra
2026-08-30 11:44:43 +08:00
committed by GitHub
co-authored by Justin Skowyra
parent 7aba0305a7
commit 6e482b5ee2
12 changed files with 807 additions and 32 deletions
+2 -1
View File
@@ -31,7 +31,8 @@
"prepare": "npm run build",
"validate:registry": "node scripts/validate-registry.mjs",
"snapshot": "curl -sf https://awesome-dsh-plugin.com/plugins.json -o data/registry-snapshot.json && curl -sf https://awesome-dsh-plugin.com/readmes.json -o data/readmes-snapshot.json",
"test:web": "vitest run --config vitest.web.config.ts",
"check:web-auth-capture": "node scripts/check-web-auth-capture.mjs",
"test:web": "node scripts/check-web-auth-capture.mjs && vitest run --config vitest.web.config.ts",
"build:site": "node scripts/build-site.mjs"
},
"dependencies": {
+82
View File
@@ -0,0 +1,82 @@
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const FORBIDDEN = [
{ label: 'BrowserContext tracing', pattern: /\.\s*tracing\s*\./u },
{ label: 'HAR recording', pattern: /\brecordHar(?:Content|Mode|OmitContent|Path)?\b/u },
{ label: 'Playwright trace option', pattern: /\btrace\s*:\s*(?:true|['"`](?:on|retain-on-failure|on-first-retry)['"`])/u },
{ label: 'trace CLI flag', pattern: /--trace(?:=|\s)/u },
{ label: 'HAR CLI flag', pattern: /--(?:save-|record-)?har(?:=|\s)/iu },
{ label: 'trace environment switch', pattern: /\b(?:PLAYWRIGHT|PW_TEST)[A-Z_]*TRACE[A-Z_]*\b/u },
]
function browserSources(web = resolve(ROOT, 'tests', 'web')) {
const sources = []
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name)
if (entry.isDirectory()) {
if (entry.name !== 'fixtures') visit(path)
continue
}
if (!/\.(?:[cm]?[jt]sx?)$/u.test(entry.name)
|| /\.(?:spec|test)\.(?:[cm]?[jt]sx?)$/u.test(entry.name)) continue
sources.push(path)
}
}
visit(web)
return sources
}
function scan(label, text) {
const matches = FORBIDDEN
.filter(rule => rule.pattern.test(text))
.map(rule => rule.label)
return matches.length === 0 ? [] : [`${label}: ${matches.join(', ')}`]
}
export function checkAuthenticatedBrowserLane(paths = []) {
const failures = []
if (paths.length > 0) {
for (const path of paths) failures.push(...scan(path, readFileSync(resolve(path), 'utf8')))
return failures
}
for (const path of [...browserSources(), resolve(ROOT, 'vitest.web.config.ts')]) {
failures.push(...scan(path, readFileSync(path, 'utf8')))
}
const packageJson = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8'))
const testWeb = packageJson?.scripts?.['test:web']
if (typeof testWeb !== 'string' || !testWeb.includes('node scripts/check-web-auth-capture.mjs')) {
failures.push('package.json: test:web must run the authenticated-lane guard before Vitest')
} else {
failures.push(...scan('package.json scripts.test:web', testWeb))
}
const workflow = readFileSync(resolve(ROOT, '.github', 'workflows', 'ci.yml'), 'utf8')
if (!workflow.includes('npm run test:web')) {
failures.push('.github/workflows/ci.yml: Web E2E must run through npm run test:web')
}
failures.push(...scan('.github/workflows/ci.yml', workflow))
return failures
}
const invokedDirectly = process.argv[1] !== undefined
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly) {
const args = process.argv.slice(2)
const sourceRoot = args[0] === '--browser-source-root' ? args[1] : undefined
const failures = sourceRoot === undefined
? checkAuthenticatedBrowserLane(args)
: browserSources(resolve(sourceRoot)).flatMap(path => scan(path, readFileSync(path, 'utf8')))
if (failures.length > 0) {
process.stderr.write(`authenticated browser capture guard failed:\n${failures.join('\n')}\n`)
process.exitCode = 1
} else {
process.stdout.write('authenticated browser capture guard passed\n')
}
}
+15
View File
@@ -0,0 +1,15 @@
# Authenticated browser lane contract
The Web E2E scaffold exchanges DSH's process launch token with Node `fetch`,
seeds the returned session cookie into a fresh Playwright `BrowserContext`, and
navigates only to the clean loopback URL.
Playwright tracing and HAR recording are forbidden for every file in this
lane. Both artifact formats retain authenticated request cookies by design;
redacting test output after capture cannot make those artifacts safe. Do not
enable `BrowserContext.tracing`, `recordHar`, Playwright `trace` options, or
trace/HAR CLI flags for `tests/web/**/*.e2e.ts`.
`npm run test:web` runs `scripts/check-web-auth-capture.mjs` before Vitest. The
guard scans the authenticated browser sources, Web Vitest config, package
script, and CI workflow, and fails closed if trace or HAR capture is enabled.
+42
View File
@@ -0,0 +1,42 @@
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('authenticated browser artifact guard', () => {
it('passes the current Web E2E sources, config, package script, and CI entrypoint', () => {
const result = spawnSync(process.execPath, ['scripts/check-web-auth-capture.mjs'], {
cwd: process.cwd(),
encoding: 'utf8',
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('authenticated browser capture guard passed')
})
it('discovers a nested non-e2e helper and fails closed on trace or HAR capture', () => {
const root = mkdtempSync(join(tmpdir(), 'dshm-auth-capture-guard-'))
try {
const nested = join(root, 'helpers')
mkdirSync(nested)
const helper = join(nested, 'browser-helper.ts')
writeFileSync(helper, [
"await context.tracing.start({ screenshots: true })",
"await browser.newContext({ recordHar: { path: 'authenticated.har' } })",
].join('\n'))
const result = spawnSync(process.execPath, [
'scripts/check-web-auth-capture.mjs',
'--browser-source-root',
root,
], {
cwd: process.cwd(),
encoding: 'utf8',
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('BrowserContext tracing')
expect(result.stderr).toContain('HAR recording')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
+2 -2
View File
@@ -14,7 +14,7 @@
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { dshAvailable, launchMarketScaffold } from './scaffold.ts'
import { dshAvailable, launchMarketScaffold, openMarketPage } from './scaffold.ts'
import type { WebScaffold } from './scaffold.ts'
describe.skipIf(!dshAvailable())('web e2e: card header', () => {
@@ -23,7 +23,7 @@ describe.skipIf(!dshAvailable())('web e2e: card header', () => {
s = await launchMarketScaffold()
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
await page.goto(s.baseUrl, { waitUntil: 'load' })
await openMarketPage(page, s)
for (let i = 0; i < 6; i++) {
const b = page.getByRole('button', { name: /^(Continue|继续|Configure later|稍后配置)$/ }).first()
try { await b.waitFor({ timeout: i === 0 ? 30_000 : 3000 }); await b.click() } catch { break }
+2 -2
View File
@@ -4,7 +4,7 @@
*/
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { dshAvailable, launchMarketScaffold } from './scaffold.ts'
import { dshAvailable, launchMarketScaffold, openMarketPage } from './scaffold.ts'
import type { WebScaffold } from './scaffold.ts'
describe.skipIf(!dshAvailable())('web e2e: category chip order stays put', () => {
@@ -13,7 +13,7 @@ describe.skipIf(!dshAvailable())('web e2e: category chip order stays put', () =>
s = await launchMarketScaffold()
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
await page.goto(s.baseUrl, { waitUntil: 'load' })
await openMarketPage(page, s)
for (let i = 0; i < 6; i++) {
const b = page.getByRole('button', { name: /^(Continue|继续|Configure later|稍后配置)$/ }).first()
try { await b.waitFor({ timeout: i === 0 ? 30_000 : 3000 }); await b.click() } catch { break }
+24 -5
View File
@@ -22,7 +22,7 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { dshAvailable, launchMarketScaffold } from './scaffold.ts'
import { dshAvailable, exchangeProcessLaunchToken, launchMarketScaffold } from './scaffold.ts'
import type { WebScaffold } from './scaffold.ts'
const HAS_DSH = dshAvailable()
@@ -145,12 +145,31 @@ describe.skipIf(!HAS_DSH).sequential('web e2e: the real install chain', () => {
//
// A host without the service (every dsh before 0.1.0-rc.7) serves no
// namespaces at all, so this reads as skipped rather than failed there.
const response = await fetch(`${base}/api/settings.describe`, {
const exchange = await exchangeProcessLaunchToken(scaffold.baseUrl, scaffold.processLaunchUrl)
const headers = {
'content-type': 'application/json',
...(exchange === null ? {} : { cookie: `${exchange.cookie.name}=${exchange.cookie.value}` }),
}
const request = (path: string, method: string): Promise<Response> => fetch(`${base}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId: '1', method: 'settings.describe', payload: { args: {} } }),
headers,
body: JSON.stringify({ type: 'client-request', rpcId: '1', method, payload: { args: {} } }),
})
const body = (await response.json()) as { result?: { ok?: boolean; value?: { namespaces?: { ns: string }[] } } }
let response = await request('/api/settings/describe', 'settings/describe')
if (response.status === 404) {
await response.text()
response = await request('/api/settings.describe', 'settings.describe')
}
const raw = await response.text()
expect(response.status, `settings describe HTTP ${String(response.status)}: ${raw.slice(0, 300)}`).toBe(200)
let body: { result?: { ok?: boolean; value?: { namespaces?: { ns: string }[] } } }
try {
body = JSON.parse(raw) as typeof body
} catch {
throw new Error(`settings describe returned non-JSON: ${raw.slice(0, 300)}`)
}
// Assert the list arrived at all: an early return on a missing field
// would let this pass while proving nothing, which is how the first
// draft of this spec stayed green against a build that never shipped
+2 -2
View File
@@ -13,7 +13,7 @@
import { chromium } from 'playwright'
import type { Browser, Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { dshAvailable, launchMarketScaffold, watchConsole } from './scaffold.ts'
import { dshAvailable, launchMarketScaffold, openMarketPage, watchConsole } from './scaffold.ts'
import type { WebScaffold } from './scaffold.ts'
const HAS_DSH = dshAvailable()
@@ -29,7 +29,7 @@ describe.skipIf(!HAS_DSH)('web e2e: plugin market', () => {
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1500, height: 950 } })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await openMarketPage(page, scaffold)
// A fresh home greets with onboarding dialogs (testing notice, API-key
// prompt, …); click through whichever appear until none are left.
const passes = /^(Continue|继续|Configure later|稍后配置)$/
+299
View File
@@ -0,0 +1,299 @@
import type { Page } from 'playwright'
import { describe, expect, it, vi } from 'vitest'
import {
createStartupOutputCapture,
exchangeProcessLaunchToken,
openMarketPage,
processLaunchUrlFromOutput,
redactAuthenticationSecrets,
withFailureCleanup,
} from './scaffold.ts'
const baseUrl = 'http://127.0.0.1:43123'
const expires = 'Wed, 01 Jan 2031 00:00:00 GMT'
function sessionCookie(value = 'session-value'): string {
return `dsh-auth-test=${value}; Max-Age=2592000; Path=/; Expires=${expires}; HttpOnly; SameSite=Strict`
}
function exchangeResponse(options: {
status?: number
location?: string
cookie?: string
body?: string
} = {}): Response {
const headers = new Headers()
if (options.location !== null) headers.set('location', options.location ?? '/')
if (options.cookie !== null) headers.append('set-cookie', options.cookie ?? sessionCookie())
return new Response(options.body ?? 'discarded exchange body', {
status: options.status ?? 303,
headers,
})
}
function fakePage(
addCookies: ReturnType<typeof vi.fn> = vi.fn(async () => {}),
): { page: Page; addCookies: ReturnType<typeof vi.fn>; goto: ReturnType<typeof vi.fn> } {
const goto = vi.fn(async () => null)
return {
page: {
context: () => ({ addCookies }),
goto,
} as unknown as Page,
addCookies,
goto,
}
}
function asFetch(mock: ReturnType<typeof vi.fn>): typeof fetch {
return mock as unknown as typeof fetch
}
describe('alpha process launch URL capture', () => {
it('keeps the latest valid same-origin root and accepts a legacy plain root', () => {
const output = [
'dsh web: http://127.0.0.1:43123/?token=first-launch',
'dsh web: https://example.test/?token=foreign',
'dsh web: http://127.0.0.1:43123/?token=latest-launch',
].join('\n')
expect(processLaunchUrlFromOutput(baseUrl, output))
.toBe('http://127.0.0.1:43123/?token=latest-launch')
expect(processLaunchUrlFromOutput(baseUrl, 'dsh web: http://127.0.0.1:43123/'))
.toBe('http://127.0.0.1:43123/')
})
it('rejects foreign origins, non-root paths, fragments, extra query input, and malformed lines', () => {
const output = [
'dsh web: https://example.test/?token=stolen',
'dsh web: http://127.0.0.1:43123/not-root?token=wrong',
'dsh web: http://127.0.0.1:43123/?token=wrong#fragment',
'dsh web: http://127.0.0.1:43123/?token=right&next=wrong',
'dsh web: definitely-not-a-url',
].join('\n')
expect(processLaunchUrlFromOutput(baseUrl, output)).toBeNull()
})
it('retains a split launch line before a noisy tail rolls it out of diagnostics', () => {
const capture = createStartupOutputCapture(baseUrl, 128)
capture.push('ordinary startup\ndsh web: http://127.0.0.1:43123/?token=split-')
expect(capture.processLaunchUrl).toBeNull()
capture.push(`across-chunks\n${'noisy tail '.repeat(80)}`)
expect(capture.processLaunchUrl)
.toBe('http://127.0.0.1:43123/?token=split-across-chunks')
expect(capture.outputTail).toContain('?token=<redacted>')
expect(capture.outputTail).not.toContain('split-across-chunks')
expect(capture.outputTail).not.toContain('across-chunks')
})
it('never exposes an incomplete launch line through the diagnostic tail', () => {
const capture = createStartupOutputCapture(baseUrl, 32)
capture.push('safe completed line\n')
capture.push('dsh web: http://127.0.0.1:43123/?token=bare-suffix-must-not-escape')
expect(capture.processLaunchUrl).toBeNull()
expect(capture.outputTail).toBe('safe completed line\n')
expect(capture.outputTail).not.toContain('bare-suffix-must-not-escape')
})
it('updates retained state only when a newer valid completed line arrives', () => {
const capture = createStartupOutputCapture(baseUrl)
capture.push('dsh web: http://127.0.0.1:43123/?token=first\n')
capture.push('dsh web: https://foreign.test/?token=ignored\n')
expect(capture.processLaunchUrl).toBe('http://127.0.0.1:43123/?token=first')
capture.push('dsh web: http://127.0.0.1:43123/?token=sec')
expect(capture.processLaunchUrl).toBe('http://127.0.0.1:43123/?token=first')
capture.push('ond\n')
expect(capture.processLaunchUrl).toBe('http://127.0.0.1:43123/?token=second')
})
it('redacts launch tokens, Cookie values, Set-Cookie values, and exact bare secrets', () => {
expect(redactAuthenticationSecrets(
'GET /?token=launch-secret; Set-Cookie: dsh-auth-x=session-secret; Cookie: dsh-auth-x=session-secret',
['launch-secret', 'session-secret'],
)).not.toMatch(/launch-secret|session-secret/u)
})
})
describe('failed boot ownership', () => {
it('cleans the spawned child, registry, and initial home exactly once without retaining a cause', async () => {
const stopChild = vi.fn(async () => {})
const closeRegistry = vi.fn(async () => {})
const removeHome = vi.fn()
let caught: unknown
try {
await withFailureCleanup(
async () => await withFailureCleanup(
async () => { throw new Error(`boot timeout at ${baseUrl}/?token=cleanup-secret`) },
stopChild,
['cleanup-secret'],
),
async () => {
try {
await closeRegistry()
} finally {
removeHome()
}
},
)
} catch (error) {
caught = error
}
expect(stopChild).toHaveBeenCalledOnce()
expect(closeRegistry).toHaveBeenCalledOnce()
expect(removeHome).toHaveBeenCalledOnce()
expect(caught).toBeInstanceOf(Error)
const reconstructed = caught as Error & { cause?: unknown }
expect(reconstructed.message).not.toContain('cleanup-secret')
expect(reconstructed.message).toContain('?token=<redacted>')
expect(reconstructed.cause).toBeUndefined()
})
it('does not clean a successful boot attempt', async () => {
const cleanup = vi.fn(async () => {})
await expect(withFailureCleanup(async () => 'ready', cleanup)).resolves.toBe('ready')
expect(cleanup).not.toHaveBeenCalled()
})
})
describe('Node-only alpha process launch token exchange', () => {
it('validates the manual 303, parses the exact cookie, and consumes the response body', async () => {
const response = exchangeResponse()
const fetch_ = vi.fn(async () => response)
const result = await exchangeProcessLaunchToken(
baseUrl,
`${baseUrl}/?token=process-launch-secret`,
asFetch(fetch_),
)
expect(fetch_).toHaveBeenCalledWith(`${baseUrl}/?token=process-launch-secret`, {
redirect: 'manual',
signal: expect.any(AbortSignal),
})
expect(result).toEqual({
cookie: {
name: 'dsh-auth-test',
value: 'session-value',
url: `${baseUrl}/`,
expires: Date.parse(expires) / 1000,
httpOnly: true,
secure: false,
sameSite: 'Strict',
},
})
expect(response.bodyUsed).toBe(true)
})
it('retains legacy plain-host behavior without making an exchange', async () => {
const fetch_ = vi.fn()
expect(await exchangeProcessLaunchToken(baseUrl, `${baseUrl}/`, asFetch(fetch_))).toBeNull()
expect(fetch_).not.toHaveBeenCalled()
})
it('rejects foreign redirects and unsafe cookie scope or attributes', async () => {
const foreign = vi.fn(async () => exchangeResponse({ location: 'https://example.test/' }))
await expect(exchangeProcessLaunchToken(
baseUrl,
`${baseUrl}/?token=redirect-secret`,
asFetch(foreign),
)).rejects.toThrow('redirected outside the clean root')
const domainCookie = vi.fn(async () => exchangeResponse({
cookie: `${sessionCookie()}; Domain=example.test`,
}))
await expect(exchangeProcessLaunchToken(
baseUrl,
`${baseUrl}/?token=cookie-scope-secret`,
asFetch(domainCookie),
)).rejects.toThrow('non-host-only cookie')
})
it('seeds the parsed cookie and navigates only to the clean base URL', async () => {
const response = exchangeResponse()
const fetch_ = vi.fn(async () => response)
const { page, addCookies, goto } = fakePage()
await openMarketPage(page, {
baseUrl,
processLaunchUrl: `${baseUrl}/?token=process-launch-secret`,
}, asFetch(fetch_))
expect(addCookies).toHaveBeenCalledExactlyOnceWith([expect.objectContaining({
name: 'dsh-auth-test',
value: 'session-value',
url: `${baseUrl}/`,
httpOnly: true,
sameSite: 'Strict',
})])
expect(goto).toHaveBeenCalledExactlyOnceWith(baseUrl, { waitUntil: 'load' })
expect(JSON.stringify(goto.mock.calls)).not.toMatch(/process-launch-secret|session-value/u)
})
it('fully redacts both credentials from seeding failures and drops the original cause', async () => {
const token = 'never-print-this-process-token'
const session = 'never-print-this-session-cookie'
const fetch_ = vi.fn(async () => exchangeResponse({ cookie: sessionCookie(session) }))
const original = new Error(`seed failed for ${session} from ${baseUrl}/?token=${token}`, {
cause: new Error(`nested ${token} ${session}`),
})
const { page, goto } = fakePage(vi.fn(async () => { throw original }))
let caught: unknown
try {
await openMarketPage(page, { baseUrl, processLaunchUrl: `${baseUrl}/?token=${token}` }, asFetch(fetch_))
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Error)
const redacted = caught as Error & { cause?: unknown }
expect(redacted.message).not.toMatch(/never-print-this/u)
expect(redacted.message).toContain('<redacted>')
expect(redacted.cause).toBeUndefined()
expect(goto).not.toHaveBeenCalled()
})
it('redacts a returned cookie even when exchange validation fails', async () => {
const session = 'returned-cookie-must-not-leak'
const fetch_ = vi.fn(async () => exchangeResponse({
status: 401,
cookie: sessionCookie(session),
}))
let caught: unknown
try {
await exchangeProcessLaunchToken(
baseUrl,
`${baseUrl}/?token=launch-token-must-not-leak`,
asFetch(fetch_),
)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Error)
const redacted = caught as Error & { cause?: unknown }
expect(redacted.message).not.toMatch(/must-not-leak/u)
expect(redacted.cause).toBeUndefined()
})
it('reads the refreshed process launch URL on each open after a restart', async () => {
let current = `${baseUrl}/?token=first-process-launch`
const fetch_ = vi.fn(async (url: string | URL | Request) => {
const value = String(url).includes('first-process-launch') ? 'first-session' : 'second-session'
return exchangeResponse({ cookie: sessionCookie(value) })
})
const { page, addCookies, goto } = fakePage()
const scaffold = {
baseUrl,
get processLaunchUrl() { return current },
}
await openMarketPage(page, scaffold, asFetch(fetch_))
current = `${baseUrl}/?token=second-process-launch`
await openMarketPage(page, scaffold, asFetch(fetch_))
expect(fetch_.mock.calls.map(call => call[0])).toEqual([
`${baseUrl}/?token=first-process-launch`,
`${baseUrl}/?token=second-process-launch`,
])
expect(addCookies.mock.calls.map(call => call[0][0].value)).toEqual(['first-session', 'second-session'])
expect(goto.mock.calls).toEqual([
[baseUrl, { waitUntil: 'load' }],
[baseUrl, { waitUntil: 'load' }],
])
})
})
+326 -11
View File
@@ -61,12 +61,304 @@ export function dshAvailable(): boolean {
export interface WebScaffold {
baseUrl: string
/** Root URL printed for this process launch. Alpha hosts add their process
* launch token; legacy hosts print the clean root. Never navigate to this
* URL directly: `openMarketPage` exchanges it outside browser navigation. */
readonly processLaunchUrl: string
home: string
/** Stop dsh and boot it again on the same DSH_HOME, same port. */
restart(): Promise<void>
close(): Promise<void>
}
/**
* Read the latest trustworthy process launch URL from dsh's startup log.
* Older hosts print the plain root; token-authenticated hosts print the same
* root with a process launch token. Only this scaffold's exact origin/root and
* exact token shape are accepted, so unrelated output cannot redirect a
* browser or API request elsewhere.
*/
export function processLaunchUrlFromOutput(baseUrl: string, output: string): string | null {
let selected: string | null = null
for (const line of output.split(/\r?\n/u)) {
const match = /^dsh web:\s+(https?:\/\/\S+)\s*$/u.exec(line)
if (match === null) continue
const candidate = trustedProcessLaunchUrl(baseUrl, match[1])
if (candidate !== null) selected = candidate.href
}
return selected
}
/** Retain a parsed launch URL before the rolling diagnostic tail drops it. */
export function createStartupOutputCapture(baseUrl: string, tailLimit = 8192): {
readonly outputTail: string
readonly processLaunchUrl: string | null
push(chunk: Buffer | string): void
} {
let outputTail = ''
let pendingLine = ''
let processLaunchUrl: string | null = null
return {
get outputTail() { return outputTail },
get processLaunchUrl() { return processLaunchUrl },
push(chunk) {
const text = chunk.toString()
const pending = pendingLine + text
const completeEnd = pending.lastIndexOf('\n')
if (completeEnd >= 0) {
const completed = pending.slice(0, completeEnd + 1)
const selected = processLaunchUrlFromOutput(baseUrl, completed)
if (selected !== null) processLaunchUrl = selected
pendingLine = pending.slice(completeEnd + 1)
const token = processLaunchUrl === null
? null
: new URL(processLaunchUrl).searchParams.get('token')
// Sanitize a complete line before it can enter the rolling buffer.
// An incomplete line stays private: truncating raw output first can
// strand a bare launch-token suffix after its `?token=` prefix falls
// off the left edge.
outputTail = (outputTail + redactAuthenticationSecrets(
completed,
token === null ? [] : [token],
)).slice(-tailLimit)
} else {
pendingLine = pending
}
// A non-newline progress stream must not grow without bound. A valid
// dsh startup line is small and console.log always terminates it.
pendingLine = pendingLine.slice(-tailLimit)
},
}
}
/** Never put a process launch token or returned session cookie into logs. */
export function redactAuthenticationSecrets(output: string, exactSecrets: readonly string[] = []): string {
let redacted = output
.replace(/([?&]token=)[^&\s#"'<>)]{1,}/giu, '$1<redacted>')
.replace(/(\bset-cookie\s*:\s*[^=;,\s]+)=([^;\r\n]*)/giu, '$1=<redacted>')
.replace(/(\bcookie\s*:\s*[^=;,\s]+)=([^;\r\n]*)/giu, '$1=<redacted>')
for (const secret of exactSecrets) {
if (secret !== '') redacted = redacted.split(secret).join('<redacted>')
}
return redacted
}
/** Run cleanup exactly once on failure and rethrow only reconstructed text. */
export async function withFailureCleanup<T>(
attempt: () => Promise<T>,
cleanup: () => Promise<void>,
exactSecrets: readonly string[] = [],
): Promise<T> {
try {
return await attempt()
} catch (error) {
const details = [error instanceof Error ? error.stack ?? error.message : String(error)]
try {
await cleanup()
} catch (cleanupError) {
details.push('failure cleanup also failed:')
details.push(cleanupError instanceof Error ? cleanupError.stack ?? cleanupError.message : String(cleanupError))
}
throw new Error(redactAuthenticationSecrets(details.join('\n'), exactSecrets))
}
}
function cleanRootUrl(baseUrl: string): URL {
const clean = new URL(baseUrl)
clean.pathname = '/'
clean.search = ''
clean.hash = ''
return clean
}
function trustedProcessLaunchUrl(baseUrl: string, value: string): URL | null {
try {
const expected = cleanRootUrl(baseUrl)
const candidate = new URL(value)
if (candidate.origin !== expected.origin || candidate.pathname !== '/'
|| candidate.hash !== '' || candidate.username !== '' || candidate.password !== '') return null
if (candidate.search === '') return candidate
const entries = [...candidate.searchParams.entries()]
if (entries.length !== 1 || entries[0]?.[0] !== 'token' || entries[0][1] === '') return null
return candidate
} catch {
return null
}
}
interface BrowserSeedCookie {
name: string
value: string
url: string
expires: number
httpOnly: true
secure: boolean
sameSite: 'Strict'
}
export interface ProcessLaunchExchange {
cookie: BrowserSeedCookie
}
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u
const COOKIE_VALUE = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/u
function returnedSetCookies(headers: Headers): string[] {
const withSetCookie = headers as Headers & { getSetCookie?: () => string[] }
const exact = withSetCookie.getSetCookie?.()
if (exact !== undefined && exact.length > 0) return exact
const combined = headers.get('set-cookie')
return combined === null ? [] : [combined]
}
/** Extract only a potential value for failure redaction, never for trust. */
function possibleCookieSecret(header: string): string {
const pair = header.split(';', 1)[0]?.trim() ?? ''
const separator = pair.indexOf('=')
if (separator <= 0) return ''
const raw = pair.slice(separator + 1).trim()
return raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw
}
function parseSessionCookie(header: string, expected: URL): BrowserSeedCookie {
if (/[\r\n]/u.test(header)) throw new Error('process launch token exchange returned a multiline Set-Cookie')
const segments = header.split(';').map(segment => segment.trim())
const pair = segments.shift() ?? ''
const separator = pair.indexOf('=')
if (separator <= 0) throw new Error('process launch token exchange returned a malformed cookie pair')
const name = pair.slice(0, separator).trim()
const rawValue = pair.slice(separator + 1).trim()
const value = rawValue.startsWith('"') && rawValue.endsWith('"')
? rawValue.slice(1, -1)
: rawValue
if (!COOKIE_NAME.test(name) || !COOKIE_VALUE.test(value)) {
throw new Error('process launch token exchange returned a cookie with unsafe name or value syntax')
}
const attributes = new Map<string, string | true>()
for (const segment of segments) {
if (segment === '') continue
const at = segment.indexOf('=')
const attribute = (at < 0 ? segment : segment.slice(0, at)).trim().toLowerCase()
const attributeValue = at < 0 ? true : segment.slice(at + 1).trim()
if (attributes.has(attribute)) throw new Error(`process launch token exchange duplicated ${attribute}`)
attributes.set(attribute, attributeValue)
}
const allowed = new Set(['max-age', 'path', 'expires', 'httponly', 'secure', 'samesite', 'domain'])
for (const attribute of attributes.keys()) {
if (!allowed.has(attribute)) throw new Error(`process launch token exchange returned unsupported ${attribute}`)
}
if (attributes.has('domain')) throw new Error('process launch token exchange returned a non-host-only cookie')
if (attributes.get('path') !== '/') throw new Error('process launch token exchange cookie Path was not /')
if (attributes.get('httponly') !== true) throw new Error('process launch token exchange cookie was not HttpOnly')
if (attributes.get('samesite') !== 'Strict') throw new Error('process launch token exchange cookie was not SameSite=Strict')
const secure = attributes.get('secure') === true
if (attributes.has('secure') && attributes.get('secure') !== true) {
throw new Error('process launch token exchange cookie Secure was not a flag')
}
if (secure !== (expected.protocol === 'https:')) {
throw new Error('process launch token exchange cookie Secure did not match the origin scheme')
}
const maxAge = attributes.get('max-age')
const maxAgeSeconds = typeof maxAge === 'string' ? Number(maxAge) : Number.NaN
if (typeof maxAge !== 'string' || !/^\d+$/u.test(maxAge)
|| !Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds <= 0) {
throw new Error('process launch token exchange cookie Max-Age was not positive')
}
const expires = attributes.get('expires')
const expiresAt = typeof expires === 'string' ? Date.parse(expires) : Number.NaN
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
throw new Error('process launch token exchange cookie Expires was not in the future')
}
return {
name,
value,
url: expected.href,
expires: Math.floor(expiresAt / 1000),
httpOnly: true,
secure,
sameSite: 'Strict',
}
}
/**
* Exchange the alpha credential entirely through Node fetch. No Playwright
* request, trace, HAR, page, or browser URL ever observes the launch token or
* raw Set-Cookie header. Legacy hosts return null without any network call.
*/
export async function exchangeProcessLaunchToken(
baseUrl: string,
processLaunchUrl: string,
fetch_: typeof fetch = fetch,
): Promise<ProcessLaunchExchange | null> {
const exactSecrets: string[] = []
try {
const expected = cleanRootUrl(baseUrl)
const launch = trustedProcessLaunchUrl(baseUrl, processLaunchUrl)
if (launch === null) throw new Error('dsh printed an untrusted process launch URL')
const tokens = launch.searchParams.getAll('token')
const exactToken = tokens[0] ?? ''
if (exactToken === '') {
if (launch.href !== expected.href) throw new Error('legacy dsh startup URL was not the clean root')
return null
}
exactSecrets.push(exactToken)
const response = await fetch_(launch.href, {
redirect: 'manual',
signal: AbortSignal.timeout(30_000),
})
try {
const setCookies = returnedSetCookies(response.headers)
exactSecrets.push(...setCookies.map(possibleCookieSecret).filter(value => value !== ''))
if (response.status !== 303) {
throw new Error(`process launch token exchange returned HTTP ${String(response.status)}, expected 303`)
}
const location = response.headers.get('location')
if (location === null) throw new Error('process launch token exchange omitted Location')
const redirect = new URL(location, launch)
if (redirect.href !== expected.href) {
throw new Error(`process launch token exchange redirected outside the clean root: ${redirect.href}`)
}
if (setCookies.length !== 1) {
throw new Error(`process launch token exchange returned ${String(setCookies.length)} Set-Cookie headers`)
}
return { cookie: parseSessionCookie(setCookies[0]!, expected) }
} finally {
if (!response.bodyUsed) await response.arrayBuffer()
}
} catch (error) {
const detail = error instanceof Error ? error.stack ?? error.message : String(error)
throw new Error(`process launch token exchange failed:\n${redactAuthenticationSecrets(detail, exactSecrets)}`)
}
}
/**
* Seed only the safely parsed session cookie, then navigate to the clean root.
* See tests/web/AUTHENTICATED-LANE.md: traces and HAR are forbidden here.
*/
export async function openMarketPage(
page: Page,
scaffold: Pick<WebScaffold, 'baseUrl' | 'processLaunchUrl'>,
fetch_: typeof fetch = fetch,
): Promise<void> {
const exactSecrets: string[] = []
try {
const exchange = await exchangeProcessLaunchToken(scaffold.baseUrl, scaffold.processLaunchUrl, fetch_)
if (exchange !== null) {
exactSecrets.push(exchange.cookie.value)
await page.context().addCookies([exchange.cookie])
}
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
} catch (error) {
const launch = trustedProcessLaunchUrl(scaffold.baseUrl, scaffold.processLaunchUrl)
const token = launch?.searchParams.get('token')
if (token !== null && token !== undefined) exactSecrets.push(token)
const detail = error instanceof Error ? error.stack ?? error.message : String(error)
throw new Error(`failed to open authenticated dsh market page:\n${redactAuthenticationSecrets(detail, exactSecrets)}`)
}
}
export interface ScaffoldOptions {
/**
* Fixture directories under `tests/web/fixtures` to publish to a local
@@ -151,7 +443,7 @@ export async function launchMarketScaffold(options: ScaffoldOptions = {}): Promi
* Windows) left orphaned browser processes and the status endpoint never
* answering — surfacing as a "dsh boot timeout" with nothing actually
* wrong in this repo. */
const boot = async (): Promise<ChildProcess> => {
const boot = async (): Promise<{ child: ChildProcess; processLaunchUrl: string }> => {
const process_ = spawn(`${command} --profile web --port ${String(port)} --no-open`, {
shell: true,
cwd: DSH_CWD,
@@ -163,21 +455,30 @@ export async function launchMarketScaffold(options: ScaffoldOptions = {}): Promi
// buys nothing, since taskkill /T walks the tree by pid.
detached: process.platform !== 'win32',
})
let output = ''
const capture = (chunk: Buffer): void => { output = (output + chunk.toString()).slice(-8192) }
process_.stdout?.on('data', capture)
process_.stderr?.on('data', capture)
const output = createStartupOutputCapture(baseUrl)
process_.stdout?.on('data', chunk => { output.push(chunk as Buffer) })
process_.stderr?.on('data', chunk => { output.push(chunk as Buffer) })
return await withFailureCleanup(async () => {
const deadline = Date.now() + 120_000
let statusReady = false
for (;;) {
if (process_.exitCode !== null) throw new Error(`dsh exited ${String(process_.exitCode)}:\n${output.slice(-2000)}`)
if (process_.exitCode !== null) {
throw new Error(`dsh exited ${String(process_.exitCode)}:\n${output.outputTail.slice(-2000)}`)
}
if (!statusReady) {
try {
const res = await fetch(`${baseUrl}/dsh-market/status`, { signal: AbortSignal.timeout(2000) })
if (res.ok) break
statusReady = res.ok
} catch { /* not up yet */ }
if (Date.now() > deadline) throw new Error(`dsh boot timeout:\n${output.slice(-2000)}`)
}
const processLaunchUrl = output.processLaunchUrl
if (statusReady && processLaunchUrl !== null) return { child: process_, processLaunchUrl }
if (Date.now() > deadline) {
throw new Error(`dsh boot timeout (status=${String(statusReady)}, startup URL=${processLaunchUrl === null ? 'missing' : 'ready'}):\n${output.outputTail.slice(-2000)}`)
}
await new Promise(resolvePromise => setTimeout(resolvePromise, 1000))
}
return process_
}, async () => { await stop(process_) })
}
/**
@@ -201,10 +502,22 @@ export async function launchMarketScaffold(options: ScaffoldOptions = {}): Promi
try { process.kill(-pid, 'SIGKILL') } catch { /* already gone */ }
}
let child = await boot()
let launched = await withFailureCleanup(
boot,
async () => {
try {
await registry?.close()
} finally {
rmSync(home, { recursive: true, force: true })
}
},
)
let child = launched.child
let processLaunchUrl = launched.processLaunchUrl
return {
baseUrl,
get processLaunchUrl() { return processLaunchUrl },
home,
/**
* Stop dsh and start it again on the same DSH_HOME. This is the only way
@@ -215,7 +528,9 @@ export async function launchMarketScaffold(options: ScaffoldOptions = {}): Promi
*/
restart: async () => {
await stop(child)
child = await boot()
launched = await boot()
child = launched.child
processLaunchUrl = launched.processLaunchUrl
},
close: async () => {
await registry?.close()
+2 -2
View File
@@ -9,7 +9,7 @@
*/
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { dshAvailable, launchMarketScaffold } from './scaffold.ts'
import { dshAvailable, launchMarketScaffold, openMarketPage } from './scaffold.ts'
import type { WebScaffold } from './scaffold.ts'
describe.skipIf(!dshAvailable())('web e2e: search box stays with the sticky header', () => {
@@ -18,7 +18,7 @@ describe.skipIf(!dshAvailable())('web e2e: search box stays with the sticky head
s = await launchMarketScaffold()
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
await page.goto(s.baseUrl, { waitUntil: 'load' })
await openMarketPage(page, s)
for (let i = 0; i < 6; i++) {
const b = page.getByRole('button', { name: /^(Continue|继续|Configure later|稍后配置)$/ }).first()
try { await b.waitFor({ timeout: i === 0 ? 30_000 : 3000 }); await b.click() } catch { break }
+2
View File
@@ -4,6 +4,8 @@ import { defineConfig } from 'vitest/config'
// packed market installed, driven by real Chromium (playwright as a
// library). Serial and slow; runs as its own CI job (`npm run test:web`).
// Specs skip when no dsh CLI is reachable (see tests/web/scaffold.ts).
// This is an authenticated lane. Playwright trace and HAR capture are
// forbidden; see tests/web/AUTHENTICATED-LANE.md and the pre-test source guard.
export default defineConfig({
test: {
include: ['tests/web/**/*.e2e.ts'],