mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-28 13:12:59 +08:00
Web and Desktop now share one command pair: start:* launches the artifacts of a prior complete build and dev:* builds first, then launches. start:web is new and spells out the same source launch vector as `pnpm dsh web`; dev:web runs `pnpm run build`, the three rebuild watchers, and `dsh web` in one terminal, with --skip-build and --no-serve for the previous two-terminal flow. The launcher gate pins both scripts to the dsh CLI, and a root Makefile names the same commands as make web|dev-web|desktop|dev-desktop|build. The dev-web stage supervisor waits for stages the terminal already interrupted, escalates only survivors, forwards SIGTERM once, and reports a stage that exits on its own. Signal listeners are persistent because the tsdown watchers bundle signal-exit, which re-raises a signal when it finds no other listener.
216 lines
8.9 KiB
TypeScript
216 lines
8.9 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { TsdownBundle } from 'tsdown'
|
|
import { writeClientBuildRecord } from './client-build-environment.ts'
|
|
import {
|
|
devWebBuildEnvironment,
|
|
discoverLibraryDirs,
|
|
discoverPluginDirs,
|
|
parseDevWebArguments,
|
|
StageSupervisor,
|
|
watchClientPlugins,
|
|
} from './dev-web.ts'
|
|
import type { StageHandle } from './dev-web.ts'
|
|
|
|
/** A stage double that exits only on the listed signals or an explicit exit code. */
|
|
function fakeStage(name: string, exitOn: readonly NodeJS.Signals[]): {
|
|
handle: StageHandle
|
|
signals: NodeJS.Signals[]
|
|
exit: (code: number) => void
|
|
} {
|
|
const exit = Promise.withResolvers<number | null>()
|
|
const signals: NodeJS.Signals[] = []
|
|
return {
|
|
handle: {
|
|
name,
|
|
exited: exit.promise,
|
|
kill(signal) {
|
|
signals.push(signal)
|
|
if (exitOn.includes(signal)) exit.resolve(null)
|
|
},
|
|
},
|
|
signals,
|
|
exit: (code) => { exit.resolve(code) },
|
|
}
|
|
}
|
|
|
|
describe('StageSupervisor', () => {
|
|
it('reports a stage that exits on its own', async () => {
|
|
const stale: [string, number | null][] = []
|
|
const supervisor = new StageSupervisor((name, code) => { stale.push([name, code]) })
|
|
const tsc = fakeStage('tsc', ['SIGTERM'])
|
|
supervisor.add(tsc.handle)
|
|
tsc.exit(2)
|
|
await tsc.handle.exited
|
|
expect(stale).toEqual([['tsc', 2]])
|
|
})
|
|
|
|
it('forwards one signal, escalates survivors after the grace period, and reports none of them', async () => {
|
|
const stale: string[] = []
|
|
const supervisor = new StageSupervisor((name) => { stale.push(name) })
|
|
const polite = fakeStage('vite', ['SIGTERM'])
|
|
const stubborn = fakeStage('dsh web', ['SIGKILL'])
|
|
supervisor.add(polite.handle)
|
|
supervisor.add(stubborn.handle)
|
|
await supervisor.stop({ signal: 'SIGTERM', graceMs: 20 })
|
|
expect(polite.signals).toEqual(['SIGTERM'])
|
|
expect(stubborn.signals).toEqual(['SIGTERM', 'SIGKILL'])
|
|
expect(stale).toEqual([])
|
|
})
|
|
|
|
it('waits for stages the terminal already interrupted before signaling survivors', async () => {
|
|
const supervisor = new StageSupervisor(() => { throw new Error('unexpected stale report') })
|
|
const interrupted = fakeStage('tsdown', [])
|
|
const survivor = fakeStage('dsh web', ['SIGTERM'])
|
|
supervisor.add(interrupted.handle)
|
|
supervisor.add(survivor.handle)
|
|
const stopping = supervisor.stop({ graceMs: 20 })
|
|
interrupted.exit(130)
|
|
await stopping
|
|
expect(interrupted.signals).toEqual([])
|
|
expect(survivor.signals).toEqual(['SIGTERM'])
|
|
})
|
|
})
|
|
|
|
describe('parseDevWebArguments', () => {
|
|
it('builds, serves, and watches natively by default', () => {
|
|
expect(parseDevWebArguments([])).toEqual({ skipBuild: false, serve: true, pollInterval: undefined, appArgs: [] })
|
|
})
|
|
|
|
it('reads the polling interval with its 500ms default', () => {
|
|
expect(parseDevWebArguments(['--poll']).pollInterval).toBe(500)
|
|
expect(parseDevWebArguments(['--poll=250']).pollInterval).toBe(250)
|
|
})
|
|
|
|
it.each(['--poll=abc', '--poll=0', '--poll=-5', '--poll=1.5'])('rejects the polling interval %s', (flag) => {
|
|
expect(() => parseDevWebArguments([flag])).toThrow(`invalid --poll interval "${flag}"`)
|
|
})
|
|
|
|
it('separates its own flags from the arguments forwarded to dsh web', () => {
|
|
expect(parseDevWebArguments(['--skip-build', '--poll', '--no-open', '--port', '8080'])).toEqual({
|
|
skipBuild: true, serve: true, pollInterval: 500, appArgs: ['--no-open', '--port', '8080'],
|
|
})
|
|
})
|
|
|
|
it('runs only the rebuild watchers with --no-serve', () => {
|
|
expect(parseDevWebArguments(['--no-serve', '--skip-build'])).toMatchObject({ serve: false, skipBuild: true })
|
|
})
|
|
|
|
it('ignores the separator pnpm run forwards verbatim', () => {
|
|
expect(parseDevWebArguments(['--', '--no-open']).appArgs).toEqual(['--no-open'])
|
|
expect(parseDevWebArguments(['--no-serve', '--'])).toMatchObject({ serve: false, appArgs: [] })
|
|
})
|
|
|
|
it('rejects dsh web arguments when no server is started', () => {
|
|
expect(() => parseDevWebArguments(['--no-serve', '--no-open'])).toThrow('--no-serve leaves no dsh web process for --no-open')
|
|
})
|
|
})
|
|
|
|
it('samples one local environment at startup without validating watcher outputs', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-'))
|
|
try {
|
|
await mkdir(join(root, 'apps/web/dist'), { recursive: true })
|
|
await mkdir(join(root, 'packages/client/example/lib'), { recursive: true })
|
|
await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.2.3' }))
|
|
await writeFile(join(root, 'apps/web/dist/index.html'), '<main></main>')
|
|
await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
|
|
writeClientBuildRecord(root, {
|
|
DSH_CLIENT_BUILD_PROFILE: 'official',
|
|
DSH_CLIENT_COMMIT_HASH: 'fffffff',
|
|
DSH_CLIENT_TITLE: 'DeepSeek Harness',
|
|
DSH_CLIENT_VERSION: '1.2.2',
|
|
})
|
|
await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n')
|
|
|
|
expect(devWebBuildEnvironment(root, {
|
|
PATH: '/bin',
|
|
DSH_BUILD_CLIENT_PROFILE: 'official',
|
|
DSH_CLIENT_COMMIT_HASH: 'abc1234',
|
|
DSH_CLIENT_EXTRA: 'launch-value',
|
|
})).toEqual({
|
|
PATH: '/bin',
|
|
DSH_CLIENT_COMMIT_HASH: 'abc1234',
|
|
DSH_CLIENT_EXTRA: 'launch-value',
|
|
DSH_CLIENT_VERSION: '1.2.3',
|
|
})
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('discovers dsh.client packages with sibling roles', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
|
|
try {
|
|
const current = join(root, 'packages', 'client', 'current')
|
|
await mkdir(current, { recursive: true })
|
|
await writeFile(join(current, 'package.json'), JSON.stringify({
|
|
dsh: {
|
|
bundle: { patch: './cordis.patch.yml' },
|
|
client: { platform: 'web' },
|
|
profile: { bundles: [] },
|
|
},
|
|
}))
|
|
|
|
expect(discoverPluginDirs(root)).toEqual(['packages/client/current'])
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('discovers client-preset packages the shell links, excluding loader-delivered and test infrastructure', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-library-'))
|
|
try {
|
|
const write = async (dir: string, manifest: unknown, config: string): Promise<void> => {
|
|
await mkdir(join(root, dir), { recursive: true })
|
|
await writeFile(join(root, dir, 'package.json'), JSON.stringify(manifest))
|
|
await writeFile(join(root, dir, 'tsdown.config.ts'), config)
|
|
}
|
|
const clientPreset = "import { clientLibrary } from '../tsdown.client.ts'\nexport default clientLibrary('x', [])\n"
|
|
|
|
// Linked by the compile shell: client preset, no loader-delivered half.
|
|
await write('packages/client/linked', {}, clientPreset)
|
|
// Loader-delivered: discoverPluginDirs owns it, so it must not appear twice.
|
|
await write('packages/client/delivered', { dsh: { client: { platform: 'web' } } }, clientPreset)
|
|
// Test infrastructure builds through the preset but never enters the shell graph.
|
|
await write('packages/test-support/harness', {}, clientPreset)
|
|
// Host package with its own config: not a client-face build at all.
|
|
await write('packages/host/server', {}, "import { defineConfig } from 'tsdown'\nexport default defineConfig({})\n")
|
|
|
|
expect(discoverLibraryDirs(root)).toEqual(['packages/client/linked'])
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rebuilds a client-plugin bundle after its source changes', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
|
|
let bundles: TsdownBundle[] = []
|
|
try {
|
|
await symlink(join(import.meta.dirname, '..', 'node_modules'), join(root, 'node_modules'), 'dir')
|
|
await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@dsh-test/dev-web-watch', private: true, type: 'module' }))
|
|
await writeFile(join(root, 'tsdown.config.ts'), `
|
|
import { defineConfig } from 'tsdown'
|
|
export default defineConfig({
|
|
entry: { client: 'src.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false,
|
|
outputOptions: { entryFileNames: 'client.js' },
|
|
})
|
|
`)
|
|
const sourcePath = join(root, 'src.ts')
|
|
const bundlePath = join(root, 'lib/client.js')
|
|
await writeFile(sourcePath, 'export const version = "watch-v1"\n')
|
|
bundles = await watchClientPlugins(root, ['.'], 50)
|
|
expect(await readFile(bundlePath, 'utf8')).toContain('watch-v1')
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1_000))
|
|
await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`)
|
|
await expect.poll(async () => (await readFile(bundlePath, 'utf8')).includes('watch-v2-'), {
|
|
timeout: 10_000,
|
|
}).toBe(true)
|
|
} finally {
|
|
for (const bundle of bundles) await bundle[Symbol.asyncDispose]()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
}, 20_000)
|