test(boot): cover Web failure recovery and inject module routes
This commit is contained in:
1 parent
ac34c5deac
commit
eb9da8930e
12 files changed
+477
-17
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md
|
||||
2026-09-09-consumer-owned-startup-strictness.md: 8e3d5f0141245ba2fa2c85faba607ac1be952e37
|
||||
2026-09-09-consumer-owned-startup-strictness.zh.md: 3873b9ceeb6204939e817a53514f5e18767ececc
|
||||
2026-09-09-consumer-owned-startup-strictness.md: e009e66ead25ef0a5e6001d33663e32bc04d19d2
|
||||
2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4
|
||||
@@ -33,3 +33,7 @@ Stable required entry ids are part of application assembly. Renaming one require
|
||||
## Testing
|
||||
|
||||
App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.
|
||||
|
||||
The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together.
|
||||
|
||||
The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing.
|
||||
+4
@@ -33,3 +33,7 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
|
||||
## 测试
|
||||
|
||||
App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。
|
||||
|
||||
[Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。
|
||||
|
||||
矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: 2e74ef68cf0b8487083a2e2af0f5175c78ec5212
|
||||
README.zh.md: 553f28f3af78b18658701449678de80b086dbb15
|
||||
README.md: f5566281ab8164cf8e88c424ad5c41a22afe4444
|
||||
README.zh.md: b2cae164c639157dc4ea3fa8c83a2b52fce57707
|
||||
@@ -54,3 +54,5 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
|
||||
## Development
|
||||
|
||||
Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
|
||||
|
||||
The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.
|
||||
@@ -54,3 +54,5 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
|
||||
## 开发
|
||||
|
||||
生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
|
||||
|
||||
[Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。
|
||||
@@ -0,0 +1,404 @@
|
||||
/** Failure policy through the built Web process and native configuration watcher. */
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createServer } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FiberState } from '@deepseek-ai/cordis'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
const bin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const built = existsSync(bin) && existsSync(join(repoRoot, 'apps/web/dist/index.html'))
|
||||
const failures = [
|
||||
['import', 'missing.mjs'],
|
||||
['module evaluation', 'matrix module evaluation'],
|
||||
['schema', 'matrix schema failure'],
|
||||
['config expression', 'matrix config expression'],
|
||||
['disabled expression', 'matrix disabled expression'],
|
||||
['sync apply', 'matrix sync apply'],
|
||||
['async apply', 'matrix async apply'],
|
||||
['dependency', 'matrixMissingService'],
|
||||
] as const
|
||||
type Failure = typeof failures[number][0]
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-web-failure-matrix-'))
|
||||
const home = join(root, 'home')
|
||||
mkdirSync(home)
|
||||
const events = join(root, 'events')
|
||||
const diagnostics = join(root, 'diagnostics')
|
||||
const serverUrl = join(root, 'server-url')
|
||||
const states = join(root, 'states')
|
||||
const stop = join(root, 'stop')
|
||||
const patch = join(home, 'cordis.patch.yml')
|
||||
const watcher = join(root, 'watcher.patch.yml')
|
||||
// Native delivery remains real; completed writes bypass Chokidar's 50 ms change suppression.
|
||||
writeFileSync(watcher, JSON.stringify([{ id: 'hmr', disabled: false, config: {
|
||||
root: [], awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
|
||||
} }]) + '\n')
|
||||
writeFileSync(events, '')
|
||||
writeFileSync(diagnostics, '')
|
||||
writeFileSync(states, '{}')
|
||||
const observerPath = join(root, 'observer.mjs')
|
||||
// HMR reports through Cordis logger exporters; WARN is above their default INFO threshold.
|
||||
writeFileSync(observerPath, [
|
||||
"import { appendFileSync, writeFileSync, renameSync, rmSync } from 'node:fs'",
|
||||
"import { inspect } from 'node:util'",
|
||||
'export function apply(ctx, config) {',
|
||||
' const report = message => appendFileSync(config.path, message.args.map(arg => inspect(arg)).join(" ") + "\\n")',
|
||||
' ctx.logger.buffer.forEach(report)',
|
||||
' ctx.logger.exporter({ levels: { default: 3 }, export: report })',
|
||||
' const timer = setInterval(() => {',
|
||||
' const loader = ctx.get("loader")',
|
||||
' if (!loader) return',
|
||||
' writeFileSync(config.states + ".tmp", JSON.stringify(Object.fromEntries([...loader.entries()].map(entry => [entry.options.id, entry.fiber?.state]))))',
|
||||
' renameSync(config.states + ".tmp", config.states)',
|
||||
' }, 20)',
|
||||
' ctx.effect(() => () => { clearInterval(timer) })',
|
||||
' ctx.inject(["webServer", "connection"], scope => {',
|
||||
' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
|
||||
' scope.effect(() => () => { rmSync(config.url, { force: true }) })',
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const observer = { id: 'matrix-log-observer', name: pathToFileURL(observerPath).href, config: { path: diagnostics, url: serverUrl, states } }
|
||||
const plugin = join(root, 'probe.mjs')
|
||||
writeFileSync(plugin, [
|
||||
"import { appendFileSync, existsSync } from 'node:fs'",
|
||||
'export const Config = { "~standard": { version: 1, vendor: "matrix", validate(value) {',
|
||||
' return value.mode === "schema" ? { issues: [{ message: "matrix schema failure" }] } : { value }',
|
||||
'} } }',
|
||||
'export const apply = (ctx, config) => {',
|
||||
' if (config.mode === "sync apply") throw new Error("matrix sync apply")',
|
||||
' return activate(ctx, config)',
|
||||
'}',
|
||||
'async function activate(ctx, config) {',
|
||||
' if (config.mode === "async apply") { await Promise.resolve(); throw new Error("matrix async apply") }',
|
||||
' if (config.provider) ctx.provide(config.provider, true)',
|
||||
' if (config.mode === "detached") setImmediate(() => { void Promise.reject(new Error("matrix detached failure")) })',
|
||||
' appendFileSync(config.events, `${config.label} apply ${config.generation}\\n`)',
|
||||
' const timer = setInterval(() => { if (existsSync(config.stop)) process.emit("SIGTERM") }, 20)',
|
||||
' ctx.effect(() => () => { clearInterval(timer); appendFileSync(config.events, `${config.label} dispose ${config.generation}\\n`) })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(root, 'evaluation.mjs'), 'throw new Error("matrix module evaluation")\n')
|
||||
const url = pathToFileURL(plugin).href
|
||||
const config = (label: string, generation: number, mode = '') => ({ label, generation, mode, events, stop })
|
||||
const witness = (generation: number) => ({ id: 'matrix-witness', name: url, config: config('witness', generation) })
|
||||
const target = (id: string, failure?: Failure, generation = 1) => ({
|
||||
id,
|
||||
name: failure === 'import' ? pathToFileURL(join(root, 'missing.mjs')).href
|
||||
: failure === 'module evaluation' ? pathToFileURL(join(root, 'evaluation.mjs')).href : url,
|
||||
...(failure === 'dependency' ? { inject: ['matrixMissingService'] } : {}),
|
||||
config: config('target', generation, failure),
|
||||
})
|
||||
const render = (id: string, failure?: Failure, generation = 1) => {
|
||||
let text = `- insert: ${JSON.stringify([observer, witness(generation), target(id, failure, generation)])}`
|
||||
if (failure === 'config expression') text += `\n- id: ${id}\n config: !!js "(() => { throw new Error('matrix config expression') })()"\n`
|
||||
if (failure === 'disabled expression') text += `\n- id: ${id}\n disabled: !!js "(() => { throw new Error('matrix disabled expression') })()"\n`
|
||||
return text + '\n'
|
||||
}
|
||||
writeFileSync(patch, JSON.stringify([{ insert: [observer, witness(0)] }]) + '\n')
|
||||
return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target }
|
||||
}
|
||||
|
||||
function start(f: ReturnType<typeof fixture>, extra: string[] = []) {
|
||||
const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
|
||||
cwd: f.root,
|
||||
env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' },
|
||||
input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL',
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout?.setEncoding('utf8').on('data', (text: string) => { stdout += text })
|
||||
child.stderr?.setEncoding('utf8').on('data', (text: string) => { stderr += text })
|
||||
let exited = false
|
||||
void child.then(() => { exited = true })
|
||||
async function wait(predicate: () => boolean) {
|
||||
try {
|
||||
await expect.poll(() => {
|
||||
if (exited) throw new Error('Web process exited')
|
||||
return predicate()
|
||||
}, { timeout: 45_000 }).toBe(true)
|
||||
} catch (cause) { throw new Error(`Web condition failed\n${stdout}\n${stderr}\n${readFileSync(f.diagnostics, 'utf8')}\n${readFileSync(f.events, 'utf8')}`, { cause }) }
|
||||
}
|
||||
async function serves(currentServer = false) {
|
||||
await wait(() => /dsh web: http:\/\//u.test(stdout))
|
||||
const url = currentServer ? readFileSync(f.serverUrl, 'utf8') : /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1]
|
||||
if (!url) throw new Error('Missing Web URL')
|
||||
const auth = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(10_000) })
|
||||
const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
|
||||
if (!cookie) throw new Error('Missing Web authentication cookie')
|
||||
const response = await fetch(new URL('/', url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('__DSH_BOOT__')
|
||||
const bundlePath = /<script src="(\/plugins\/[^"]+)"/u.exec(html)?.[1]?.replaceAll('&', '&')
|
||||
if (!bundlePath) throw new Error('Missing bootstrap bundle URL')
|
||||
const bundle = await fetch(new URL(bundlePath, url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
|
||||
expect(bundle.status).toBe(200)
|
||||
expect(await bundle.text()).not.toBe('')
|
||||
}
|
||||
async function close() {
|
||||
writeFileSync(f.stop, 'stop')
|
||||
// Assertions may fail before a probe applies; forceful teardown still awaits exit.
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 10_000)
|
||||
try {
|
||||
const result = await child
|
||||
return { timedOut: result.timedOut, signal: result.signal, exitCode: result.exitCode, stderr: result.stderr, events: readFileSync(f.events, 'utf8') }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
rmSync(f.root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
const state = (id: string) => (JSON.parse(readFileSync(f.states, 'utf8')) as Record<string, number | undefined>)[id]
|
||||
return { child, wait, serves, close, state, stderr: () => stderr, logs: () => readFileSync(f.diagnostics, 'utf8'), events: () => readFileSync(f.events, 'utf8') }
|
||||
}
|
||||
|
||||
function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode?: number | undefined; stderr: string }, code: number) {
|
||||
expect(result.timedOut, result.stderr).toBe(false)
|
||||
expect(result.signal, result.stderr).toBeUndefined()
|
||||
expect(result.exitCode, result.stderr).toBe(code)
|
||||
}
|
||||
|
||||
describe.skipIf(!built)('Web process failure matrix', () => {
|
||||
for (const required of [false, true]) {
|
||||
const id = required ? 'acp' : 'matrix-optional'
|
||||
it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => {
|
||||
const f = fixture()
|
||||
writeFileSync(f.patch, f.render(id, failure))
|
||||
const app = start(f)
|
||||
try {
|
||||
if (required) {
|
||||
const result = await app.child
|
||||
exit(result, 1)
|
||||
expect(result.stdout).not.toContain('dsh web: http://')
|
||||
expect(result.stderr).toContain('required startup failure')
|
||||
expect(app.events()).toBe('witness apply 1\nwitness dispose 1\n')
|
||||
} else {
|
||||
await app.serves()
|
||||
await app.wait(() => (app.stderr() + app.logs()).includes(diagnostic))
|
||||
expect(app.stderr()).toContain('warning: 1 entry did not activate')
|
||||
expect(app.events()).toBe('witness apply 1\n')
|
||||
}
|
||||
expect(app.stderr() + app.logs()).toContain(diagnostic)
|
||||
} finally {
|
||||
const result = await app.close()
|
||||
exit(result, required ? 1 : 0)
|
||||
expect(result.events).toContain('witness dispose 1\n')
|
||||
}
|
||||
})
|
||||
|
||||
it.each(failures)(`${required ? 'required' : 'optional'} native HMR %s keeps siblings and recovers`, async (failure, diagnostic) => {
|
||||
const f = fixture()
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render(id, failure))
|
||||
await app.wait(() => app.events().includes('witness apply 1\n') && (failure === 'dependency' ? app.state(id) === FiberState.PENDING : app.logs().includes(diagnostic)))
|
||||
expect(app.events()).not.toContain('witness dispose 1\n')
|
||||
expect(app.events()).not.toContain('target apply')
|
||||
expect(readFileSync(f.patch, 'utf8')).toBe(f.render(id, failure))
|
||||
await app.serves()
|
||||
if (failure === 'dependency') {
|
||||
writeFileSync(f.patch, JSON.stringify([
|
||||
{ insert: [f.observer, f.witness(1), f.target(id, failure)] },
|
||||
{ insert: [{ id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 2), provider: 'matrixMissingService' } }] },
|
||||
]) + '\n')
|
||||
} else writeFileSync(f.patch, f.render(id, undefined, 2))
|
||||
await app.wait(() => app.events().includes(`target apply ${failure === 'dependency' ? 1 : 2}\n`))
|
||||
await app.serves()
|
||||
expect(app.stderr()).not.toContain('required startup failure')
|
||||
} finally {
|
||||
const result = await app.close()
|
||||
exit(result, 0)
|
||||
expect(result.events).toContain(`witness dispose ${failure === 'dependency' ? 1 : 2}\n`)
|
||||
expect(result.events).toContain(`target dispose ${failure === 'dependency' ? 1 : 2}\n`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
['missing', undefined, 'failed to read overlay'],
|
||||
['unreadable directory', undefined, 'failed to read overlay'],
|
||||
['malformed', 'invalid: [unclosed\n', 'failed to parse overlay'],
|
||||
['non-array', 'entries: []\n', 'top-level YAML array'],
|
||||
['non-mapping', '- null\n', 'must be a mapping'],
|
||||
])('rejects a %s explicit overlay before readiness', async (_kind, content, diagnostic) => {
|
||||
const f = fixture()
|
||||
const overlay = join(f.root, 'invalid.patch.yml')
|
||||
if (_kind === 'unreadable directory') mkdirSync(overlay)
|
||||
else if (content !== undefined) writeFileSync(overlay, content)
|
||||
const app = start(f, ['--patch', overlay])
|
||||
try {
|
||||
const result = await app.child
|
||||
exit(result, 1)
|
||||
expect(result.stderr).toContain(diagnostic)
|
||||
expect(result.stdout).not.toContain('dsh web: http://')
|
||||
expect(app.events()).toBe('')
|
||||
} finally { exit(await app.close(), 1) }
|
||||
})
|
||||
|
||||
it.each([
|
||||
['malformed', 'invalid: [unclosed\n', 'failed to parse patches'],
|
||||
['non-array', 'entries: []\n', 'top-level YAML array'],
|
||||
['non-mapping', '- null\n', 'must be a mapping'],
|
||||
])('native HMR rejects %s patches, preserves the app and accepts a correction', async (_kind, content, diagnostic) => {
|
||||
const f = fixture()
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, content)
|
||||
await app.wait(() => app.logs().includes(diagnostic))
|
||||
expect(app.events()).toBe('witness apply 0\n')
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render('matrix-optional'))
|
||||
await app.wait(() => app.events().includes('target apply 1\n'))
|
||||
await app.serves()
|
||||
} finally { exit(await app.close(), 0) }
|
||||
})
|
||||
|
||||
it('native HMR schema failure retains the existing config until a valid correction', async () => {
|
||||
const f = fixture()
|
||||
writeFileSync(f.patch, f.render('acp', undefined, 1))
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render('acp', 'schema', 2))
|
||||
await app.wait(() => app.logs().includes('matrix schema failure') && app.events().includes('witness apply 2\n'))
|
||||
expect(app.events()).toContain('target apply 1\n')
|
||||
expect(app.events()).not.toContain('target dispose 1\n')
|
||||
expect(app.events()).not.toContain('target apply 2\n')
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render('acp', undefined, 3))
|
||||
await app.wait(() => app.events().includes('target apply 3\n'))
|
||||
expect(app.events()).toContain('target dispose 1\n')
|
||||
await app.serves()
|
||||
} finally { exit(await app.close(), 0) }
|
||||
})
|
||||
|
||||
it('ignores absent and explicitly disabled required entries at startup', async () => {
|
||||
const f = fixture()
|
||||
writeFileSync(f.patch, f.render('acp', 'import') + '- id: acp\n disabled: true\n')
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
expect(app.stderr()).not.toContain('required startup failure')
|
||||
expect(app.stderr()).not.toContain('failed to import')
|
||||
} finally { exit(await app.close(), 0) }
|
||||
})
|
||||
|
||||
it('detached rejection from a hot-loaded plugin terminates and disposes the app', async () => {
|
||||
const f = fixture()
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, JSON.stringify([{ insert: [f.observer, f.witness(0), {
|
||||
id: 'matrix-detached', name: f.url, config: f.config('detached', 1, 'detached'),
|
||||
}] }]) + '\n')
|
||||
const result = await app.child
|
||||
exit(result, 1)
|
||||
expect(result.stderr).toContain('fatal load failure: Error: matrix detached failure')
|
||||
expect(app.events()).toContain('witness dispose 0\n')
|
||||
expect(app.events()).toContain('detached dispose 1\n')
|
||||
} finally { exit(await app.close(), 1) }
|
||||
})
|
||||
|
||||
it('native HMR reports a required Web server bind failure without terminating the process', async () => {
|
||||
const f = fixture()
|
||||
const blocker = createServer()
|
||||
const app = start(f)
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once('error', reject)
|
||||
blocker.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = blocker.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Missing blocker address')
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render('matrix-optional') + `- id: webserver\n config:\n host: 127.0.0.1\n port: ${address.port}\n`)
|
||||
await app.wait(() => app.logs().includes('EADDRINUSE') && app.events().includes('witness apply 1\n'))
|
||||
expect(app.stderr()).not.toContain('required startup failure')
|
||||
expect(app.events()).not.toContain('witness dispose 1\n')
|
||||
expect(existsSync(f.serverUrl)).toBe(false)
|
||||
writeFileSync(f.patch, f.render('matrix-optional', undefined, 2))
|
||||
await app.wait(() => app.events().includes('witness apply 2\n') && existsSync(f.serverUrl))
|
||||
await app.serves(true)
|
||||
} finally {
|
||||
try { exit(await app.close(), 0) } finally {
|
||||
await new Promise<void>((resolve, reject) => blocker.close((error) => { if (error) reject(error); else resolve() }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['startup', 'HMR'])('optional HTTP bind failure at %s leaves Web serving', async (phase) => {
|
||||
const f = fixture()
|
||||
const blocker = createServer()
|
||||
let app: ReturnType<typeof start> | undefined
|
||||
try {
|
||||
const plugin = join(f.root, 'http.mjs')
|
||||
writeFileSync(plugin, [
|
||||
'import { createServer } from "node:http"',
|
||||
'export async function apply(ctx, config) {',
|
||||
' const server = createServer()',
|
||||
' ctx.effect(() => () => new Promise(resolve => server.close(() => resolve())))',
|
||||
' await new Promise((resolve, reject) => { server.once("error", reject); server.listen(config.port, "127.0.0.1", resolve) })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once('error', reject)
|
||||
blocker.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = blocker.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Missing blocker address')
|
||||
const patch = JSON.stringify([{ insert: [f.observer, f.witness(0), {
|
||||
id: 'matrix-optional-http', name: pathToFileURL(plugin).href, config: { port: address.port },
|
||||
}] }]) + '\n'
|
||||
if (phase === 'startup') writeFileSync(f.patch, patch)
|
||||
app = start(f)
|
||||
const running = app
|
||||
await app.serves()
|
||||
if (phase === 'HMR') writeFileSync(f.patch, patch)
|
||||
await app.wait(() => (running.logs() + running.stderr()).includes('EADDRINUSE'))
|
||||
expect(app.events()).toBe('witness apply 0\n')
|
||||
await app.serves()
|
||||
} finally {
|
||||
try {
|
||||
if (app) exit(await app.close(), 0)
|
||||
else rmSync(f.root, { recursive: true, force: true })
|
||||
} finally {
|
||||
if (blocker.listening) {
|
||||
await new Promise<void>((resolve, reject) => blocker.close((error) => { if (error) reject(error); else resolve() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['modules', 'connection'])('native HMR recovers the shipped required %s entry', async (id) => {
|
||||
const f = fixture()
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
// Entry-level injection requirements are captured when the fiber is created.
|
||||
writeFileSync(f.patch, f.render('matrix-optional') + `- id: ${id}\n disabled: true\n`)
|
||||
await app.wait(() => app.state(id) === FiberState.DISPOSED)
|
||||
const inject = id === 'connection' ? ['webRuntime', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
|
||||
const pending = f.render('matrix-optional', undefined, 2) + `- id: ${id}\n inject: ${JSON.stringify(inject)}\n`
|
||||
writeFileSync(f.patch, pending)
|
||||
await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n'))
|
||||
expect(app.events()).not.toContain('witness dispose 2\n')
|
||||
expect(app.stderr()).not.toContain('required startup failure')
|
||||
writeFileSync(f.patch, pending + `- insert: ${JSON.stringify([{
|
||||
id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 3), provider: 'matrixMissingWebDependency' },
|
||||
}])}\n`)
|
||||
await app.wait(() => app.state(id) === FiberState.ACTIVE && existsSync(f.serverUrl))
|
||||
await app.serves(true)
|
||||
} finally { exit(await app.close(), 0) }
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: 83ba5357cf84a12105518ed057fd120584b636cb
|
||||
README.zh.md: 9e5c75838e52fda7d74b074b9c1a9dacef7932a8
|
||||
README.md: 4c9dc4a3a13cb6f24e03c927d0277e137ab97b9f
|
||||
README.zh.md: bc5b78d258270962661ab27ca7eb1a58cc61f01c
|
||||
@@ -71,6 +71,8 @@ The Node half snapshots each client bundle and available source map before publi
|
||||
|
||||
### Boot manifest injection
|
||||
|
||||
The bundle route follows the injected `webServer` lifetime: it registers when the service is ready and is removed and re-registered when that service is replaced. Module composition and `fetchBundle()` remain available without a Web server.
|
||||
|
||||
The host contributes structured index rows that inject, into `<head>`: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode.
|
||||
|
||||
### Source map
|
||||
|
||||
@@ -71,6 +71,8 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map
|
||||
|
||||
### 启动 manifest 注入
|
||||
|
||||
bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册,服务被替换时移除并重新注册。模块组合与 `fetchBundle()` 在没有 Web server 时仍可用。
|
||||
|
||||
宿主贡献结构化 index 行,并向 `<head>` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。
|
||||
|
||||
### 源码索引
|
||||
|
||||
@@ -504,6 +504,7 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Build the service: subscribe, seed, and run the activation flush.
|
||||
* Bundle routes follow the optional Web carrier's injected lifecycle.
|
||||
* @param ctx - plugin context carrying Loader and an optional Web carrier.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
@@ -540,8 +541,7 @@ export class ClientModuleRegistry extends Service {
|
||||
'client-modules: bundle route',
|
||||
)
|
||||
}
|
||||
if (ctx.get('webServer') === undefined) ctx.inject(['webServer'], registerWebCarrier)
|
||||
else registerWebCarrier(ctx)
|
||||
ctx.inject(['webServer'], registerWebCarrier)
|
||||
ctx.on('webserver/index-inject', (table) => {
|
||||
table.push(...bootInjections(this.composed))
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { Context, FiberState, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import * as modulesClient from '../src/client/index.ts'
|
||||
@@ -24,12 +24,50 @@ const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
|
||||
const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
|
||||
|
||||
let root: string | undefined
|
||||
const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => {
|
||||
await ready
|
||||
await ctx.fiber.dispose()
|
||||
}))
|
||||
if (root !== undefined) rmSync(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
it.each([false, true])('tracks the Web carrier lifetime when server-first is %s', async (serverFirst) => {
|
||||
const ctx = new Context()
|
||||
contexts.push({ ctx })
|
||||
ctx.provide('loader', { entries: () => [] })
|
||||
const routes = new Set<WebRoute>()
|
||||
const mountServer = () => ctx.plugin((serverCtx) => {
|
||||
serverCtx.provide('webServer', {
|
||||
register: (route: WebRoute) => {
|
||||
routes.add(route)
|
||||
return () => { routes.delete(route) }
|
||||
},
|
||||
} as WebServer)
|
||||
})
|
||||
let server = serverFirst ? await mountServer() : undefined
|
||||
const modules = await ctx.plugin(ClientModuleRegistry)
|
||||
const service = ctx.get('clientModules')!
|
||||
expect(service.graph().entries).toEqual([])
|
||||
expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
|
||||
if (!serverFirst) {
|
||||
expect(routes.size).toBe(0)
|
||||
server = await mountServer()
|
||||
}
|
||||
await expect.poll(() => routes.size).toBe(1)
|
||||
await server!.dispose()
|
||||
await expect.poll(() => routes.size).toBe(0)
|
||||
expect(modules.state).toBe(FiberState.ACTIVE)
|
||||
expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
|
||||
await mountServer()
|
||||
await expect.poll(() => routes.size).toBe(1)
|
||||
await modules.dispose()
|
||||
expect(routes.size).toBe(0)
|
||||
})
|
||||
|
||||
/** Create a resolvable package whose client export points at the returned path. */
|
||||
function writePackage(
|
||||
packageName: string,
|
||||
@@ -65,8 +103,10 @@ function constructWithRoute(
|
||||
entryBaseUrl?: string
|
||||
internal?: NonNullable<Context['loader']['internal']>
|
||||
} = {},
|
||||
): { context: Context; service: ClientModuleRegistry; route: WebRoute } {
|
||||
): { context: Context; service: ClientModuleRegistry; route: Promise<WebRoute> } {
|
||||
const ctx = new Context()
|
||||
const owned: typeof contexts[number] = { ctx }
|
||||
contexts.push(owned)
|
||||
ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
|
||||
ctx.provide('loader', {
|
||||
internal: options.internal,
|
||||
@@ -81,19 +121,19 @@ function constructWithRoute(
|
||||
}
|
||||
},
|
||||
})
|
||||
let route: WebRoute | undefined
|
||||
const route = Promise.withResolvers<WebRoute>()
|
||||
const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
|
||||
port: 0,
|
||||
register: (candidate) => {
|
||||
if (candidate.path === '/plugins') route = candidate
|
||||
if (candidate.path === '/plugins') route.resolve(candidate)
|
||||
return () => {}
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
}
|
||||
ctx.provide('webServer', webServer as WebServer)
|
||||
const service = new ClientModuleRegistry(ctx)
|
||||
if (route === undefined) throw new Error('client bundle route was not registered')
|
||||
return { context: ctx, service, route }
|
||||
owned.ready = route.promise
|
||||
return { context: ctx, service, route: route.promise }
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
@@ -102,7 +142,7 @@ function construct(packageNames: string[]): ClientModuleRegistry {
|
||||
}
|
||||
|
||||
/** Invoke the registered plugin route and capture status, headers, and bytes. */
|
||||
async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promise<{
|
||||
async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET'): Promise<{
|
||||
status: number
|
||||
headers: Record<string, string> | undefined
|
||||
body: Buffer
|
||||
@@ -121,7 +161,7 @@ async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promi
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
await route.handler({ method, url } as IncomingMessage, response)
|
||||
await (await route).handler({ method, url } as IncomingMessage, response)
|
||||
return { status, headers, body }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user