mirror of
https://github.com/dsh-market/dsh-market.git
synced 2026-09-28 05:03:07 +08:00
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:
co-authored by
Justin Skowyra
parent
7aba0305a7
commit
6e482b5ee2
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|稍后配置)$/
|
||||
|
||||
@@ -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' }],
|
||||
])
|
||||
})
|
||||
})
|
||||
+333
-18
@@ -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 deadline = Date.now() + 120_000
|
||||
for (;;) {
|
||||
if (process_.exitCode !== null) throw new Error(`dsh exited ${String(process_.exitCode)}:\n${output.slice(-2000)}`)
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/dsh-market/status`, { signal: AbortSignal.timeout(2000) })
|
||||
if (res.ok) break
|
||||
} catch { /* not up yet */ }
|
||||
if (Date.now() > deadline) throw new Error(`dsh boot timeout:\n${output.slice(-2000)}`)
|
||||
await new Promise(resolvePromise => setTimeout(resolvePromise, 1000))
|
||||
}
|
||||
return process_
|
||||
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.outputTail.slice(-2000)}`)
|
||||
}
|
||||
if (!statusReady) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/dsh-market/status`, { signal: AbortSignal.timeout(2000) })
|
||||
statusReady = res.ok
|
||||
} catch { /* not up yet */ }
|
||||
}
|
||||
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))
|
||||
}
|
||||
}, 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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user