Files
wehub-resource-sync d18ada4ee7
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:24:41 +08:00

415 lines
14 KiB
TypeScript

/**
* Three-host equivalence test (NEX-131 acceptance: 三端等价性测试).
*
* Goal: prove that Desktop, Web/API and CLI hosts produce byte-equal
* task-state and event payloads when driven by the same scripted yt-dlp
* lifecycle. The kernel is host-neutral; any divergence is a contract bug
* in the host adapter.
*
* What this test covers right now:
* - Each "host" instantiates its own TaskQueueAPI + MemoryPersistAdapter
* wrapped around the same FakeYtDlpExecutor (deterministic spawn /
* progress / postprocess / finish script).
* - We add the same TaskInput on each host, drive the executor scripts,
* and assert that the resulting Task snapshots and projection outputs
* are equal across all three hosts (modulo task id and timestamps).
* - Scenarios: complete success, http-429 retry-then-success, auth
* failure (terminal), stalled cancel.
*
* What is intentionally not covered yet (left as TODO for the CLI part of
* NEX-133's Phase B):
* - Real `apps/cli` envelope output equivalence: this test currently
* stands in for the CLI by spinning up a third TaskQueueAPI instance
* in the same process. Once `apps/cli --vidbee-local` ships its
* in-process driver, point the third host at that instead.
* - Real `apps/api` SSE wire-format diff: the SSE bridge serializes
* `snapshot-changed` with a projection field. Add a wire-level diff
* once the CLI and Desktop loopback also publish the same payload.
* - SQLite-backed crash recovery scenario (kill main → resume): requires
* a child-process harness, deferred to the host-specific e2e suites.
*/
import { describe, expect, it } from 'vitest'
import { TaskQueueAPI } from '../src/api'
import type { Executor, ExecutorEvents, ExecutorRun } from '../src/executor'
import { MemoryPersistAdapter } from '../src/persist'
import { projectTaskToLegacy } from '../src/projection'
import type { Task, TaskInput, TaskStatus } from '../src/types'
type Step =
| { type: 'spawn' }
| { type: 'progress'; percent: number; postprocess?: boolean }
| { type: 'success'; filePath?: string; size?: number }
| { type: 'errorRetryable'; message?: string }
| { type: 'errorFatal'; message?: string }
const SUCCESS_SCRIPT: Step[] = [
{ type: 'spawn' },
{ type: 'progress', percent: 0.5 },
{ type: 'progress', percent: 0.95, postprocess: true },
{ type: 'success', filePath: '/tmp/x.mp4', size: 1024 }
]
const HTTP_429_THEN_SUCCESS_SCRIPT_GENERATOR = (): Step[] => [
{ type: 'spawn' },
{ type: 'progress', percent: 0.2 },
{ type: 'errorRetryable', message: 'HTTP Error 429: Too Many Requests' }
]
const AUTH_REQUIRED_SCRIPT: Step[] = [
{ type: 'spawn' },
{ type: 'errorFatal', message: 'Login required' }
]
interface ScriptedExecutor extends Executor {
setNextScriptForUrl: (url: string, script: Step[]) => void
}
const makeScriptedExecutor = (initial: Map<string, Step[]>): ScriptedExecutor => {
const scripts = new Map(initial)
let pidCounter = 1000
const executor: ScriptedExecutor = {
setNextScriptForUrl(url, script) {
scripts.set(url, script)
},
run(ctx, events: ExecutorEvents): ExecutorRun {
const script = scripts.get(ctx.input.url) ?? SUCCESS_SCRIPT
const pid = ++pidCounter
let cancelled = false
queueMicrotask(() => {
if (cancelled) {
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'cancelled' },
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
return
}
for (const step of script) {
switch (step.type) {
case 'spawn':
events.onSpawn({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
pid,
pidStartedAt: 1,
kind: 'yt-dlp',
spawnedAt: 1
})
break
case 'progress':
events.onProgress({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
progress: {
percent: step.percent,
bytesDownloaded: Math.round(step.percent * 1000),
bytesTotal: 1000,
speedBps: 100,
etaMs: 100,
ticks: 1
},
enteredProcessing: Boolean(step.postprocess)
})
break
case 'success':
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: {
type: 'success',
output: {
filePath: step.filePath ?? '/tmp/out.mp4',
size: step.size ?? 1024,
durationMs: null,
sha256: null
}
},
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
return
case 'errorRetryable':
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: {
type: 'error',
error: {
category: 'http-429',
exitCode: 1,
rawMessage: step.message ?? 'http-429',
uiMessageKey: 'errors.rate_limited',
uiActionHints: [],
retryable: true,
suggestedRetryAfterMs: 50
},
exitCode: 1
},
closedAt: 1,
stdoutTail: '',
stderrTail: step.message ?? ''
})
return
case 'errorFatal':
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: {
type: 'error',
error: {
category: 'auth-required',
exitCode: 1,
rawMessage: step.message ?? 'auth required',
uiMessageKey: 'errors.auth_required',
uiActionHints: ['login'],
retryable: false,
suggestedRetryAfterMs: null
},
exitCode: 1
},
closedAt: 1,
stdoutTail: '',
stderrTail: step.message ?? ''
})
return
}
}
})
const emitCancelled = (): void => {
cancelled = true
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'cancelled' },
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
}
return {
cancel: async () => {
if (!cancelled) emitCancelled()
},
pause: async () => {
if (!cancelled) emitCancelled()
}
}
}
}
return executor
}
interface Host {
name: string
queue: TaskQueueAPI
executor: ScriptedExecutor
}
const buildHost = (name: string, scripts: Map<string, Step[]>): Host => {
const executor = makeScriptedExecutor(scripts)
return {
name,
executor,
queue: new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor,
maxConcurrency: 4,
// Make the test deterministic — all hosts use the same deterministic
// backoff so retry-scheduled timing matches.
rng: () => 0.5,
// The fake executor reports synthetic file paths; the kernel guards
// `processing → completed` with a real fs check by default. Disable
// for tests so the script's `success` step actually completes.
filePresent: () => true
})
}
}
const HOSTS: ReadonlyArray<{ name: string }> = [
{ name: 'desktop' },
{ name: 'api' },
{ name: 'cli' }
]
const buildHosts = (scripts: Map<string, Step[]>): Host[] =>
HOSTS.map((h) => buildHost(h.name, scripts))
const startAll = async (hosts: Host[]): Promise<void> => {
await Promise.all(hosts.map((h) => h.queue.start()))
}
const stopAll = async (hosts: Host[]): Promise<void> => {
await Promise.all(hosts.map((h) => h.queue.stop()))
}
const waitFor = async (
hosts: Host[],
predicate: (h: Host) => boolean,
timeoutMs = 2000
): Promise<void> => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (hosts.every(predicate)) return
await new Promise((r) => setTimeout(r, 5))
}
throw new Error('waitFor: predicate did not hold within timeout')
}
const submitOnAllHosts = async (
hosts: Host[],
input: TaskInput
): Promise<Map<string, string>> => {
const ids = new Map<string, string>()
for (const host of hosts) {
const { id } = await host.queue.add({ input })
ids.set(host.name, id)
}
return ids
}
const expectStatus = (host: Host, id: string, expected: TaskStatus): void => {
const t = host.queue.get(id)
expect(t, `${host.name}/${id}`).toBeTruthy()
expect(t?.status, `${host.name}/${id}`).toBe(expected)
}
const stripVolatileFields = (task: Readonly<Task>): unknown => {
const cloned = JSON.parse(JSON.stringify(task)) as Record<string, unknown>
delete cloned.id
delete cloned.createdAt
delete cloned.updatedAt
delete cloned.enteredStatusAt
delete cloned.pid
delete cloned.pidStartedAt
delete cloned.nextRetryAt
return cloned
}
const stripVolatileProjection = (task: Readonly<Task>): unknown => {
const proj = projectTaskToLegacy(task) as Record<string, unknown>
delete proj.id
delete proj.createdAt
delete proj.startedAt
delete proj.completedAt
delete proj.nextRetryAt
return proj
}
describe('three-host equivalence', () => {
it('completed download produces identical Task + projection across hosts', async () => {
const scripts = new Map<string, Step[]>([
['https://example.com/v/success', SUCCESS_SCRIPT]
])
const hosts = buildHosts(scripts)
await startAll(hosts)
try {
const ids = await submitOnAllHosts(hosts, {
url: 'https://example.com/v/success',
kind: 'video',
title: 'Sample',
thumbnail: 'https://example.com/t.jpg'
})
await waitFor(hosts, (h) => h.queue.get(ids.get(h.name)!)?.status === 'completed')
const snapshots = hosts.map((h) => stripVolatileFields(h.queue.get(ids.get(h.name)!)!))
const projections = hosts.map((h) =>
stripVolatileProjection(h.queue.get(ids.get(h.name)!)!)
)
for (let i = 1; i < snapshots.length; i++) {
expect(snapshots[i]).toStrictEqual(snapshots[0])
expect(projections[i]).toStrictEqual(projections[0])
}
} finally {
await stopAll(hosts)
}
})
it('http-429 → retry-scheduled lands on identical retry state across hosts', async () => {
const scripts = new Map<string, Step[]>([
['https://example.com/v/retry', HTTP_429_THEN_SUCCESS_SCRIPT_GENERATOR()]
])
const hosts = buildHosts(scripts)
await startAll(hosts)
try {
const ids = await submitOnAllHosts(hosts, {
url: 'https://example.com/v/retry',
kind: 'video'
})
await waitFor(
hosts,
(h) => h.queue.get(ids.get(h.name)!)?.status === 'retry-scheduled'
)
for (const host of hosts) expectStatus(host, ids.get(host.name)!, 'retry-scheduled')
const projections = hosts.map((h) => projectTaskToLegacy(h.queue.get(ids.get(h.name)!)!))
for (const proj of projections) {
expect(proj.status).toBe('pending')
expect(proj.subStatus).toBe('retry-scheduled')
expect(proj.errorCategory).toBe('http-429')
// FSM increments `attempt` on every retry-scheduled transition.
expect(proj.attempt).toBe(1)
}
} finally {
await stopAll(hosts)
}
})
it('auth-required is non-retryable and yields identical failed projection', async () => {
const scripts = new Map<string, Step[]>([
['https://example.com/v/auth', AUTH_REQUIRED_SCRIPT]
])
const hosts = buildHosts(scripts)
await startAll(hosts)
try {
const ids = await submitOnAllHosts(hosts, {
url: 'https://example.com/v/auth',
kind: 'video'
})
await waitFor(hosts, (h) => h.queue.get(ids.get(h.name)!)?.status === 'failed')
const projections = hosts.map((h) =>
stripVolatileProjection(h.queue.get(ids.get(h.name)!)!)
)
for (let i = 1; i < projections.length; i++) {
expect(projections[i]).toStrictEqual(projections[0])
}
const proj = projectTaskToLegacy(hosts[0]!.queue.get(ids.get('desktop')!)!)
expect(proj.status).toBe('error')
expect(proj.errorCategory).toBe('auth-required')
expect(proj.uiMessageKey).toBe('errors.auth_required')
} finally {
await stopAll(hosts)
}
})
it('cancel on a running task produces identical cancelled snapshot across hosts', async () => {
const scripts = new Map<string, Step[]>([
[
'https://example.com/v/cancel-me',
[{ type: 'spawn' }, { type: 'progress', percent: 0.3 }]
]
])
const hosts = buildHosts(scripts)
await startAll(hosts)
try {
const ids = await submitOnAllHosts(hosts, {
url: 'https://example.com/v/cancel-me',
kind: 'video'
})
await waitFor(hosts, (h) => h.queue.get(ids.get(h.name)!)?.status === 'running', 1000)
await Promise.all(hosts.map((h) => h.queue.cancel(ids.get(h.name)!, 'user')))
await waitFor(hosts, (h) => h.queue.get(ids.get(h.name)!)?.status === 'cancelled')
const projections = hosts.map((h) =>
stripVolatileProjection(h.queue.get(ids.get(h.name)!)!)
)
for (let i = 1; i < projections.length; i++) {
expect(projections[i]).toStrictEqual(projections[0])
}
} finally {
await stopAll(hosts)
}
})
})