ci: make reference and worktree tests portable (#7381)
* fix(forge): make reference and worktree tests portable * test(worktree): cover error and text cancellation reasons * test(forge): brace void arrow bodies in the reference lookup spec * test(ci): expect the joined version script path in the pre-merge check --------- Co-authored-by: imccyu <[email protected]>
This commit is contained in:
1 parent
9c69007eca
commit
e2c4d0538f
4 files changed
+200
-5
No files matched your search
@@ -32,6 +32,7 @@ describe('commandEvidence', () => {
|
||||
['git --no-pager push --force-with-lease', pushed],
|
||||
['cd repo && git push && gh pr create --fill', { ...created, pushes: true }],
|
||||
['gh pr view 12 --json url', { urls: 'referenced', pushes: false, named: [] }],
|
||||
['gh pr view 12 --repo octo/demo --json number', { urls: 'referenced', pushes: false, named: [] }],
|
||||
[`gh pr view '${url}' --json title`, { urls: 'referenced', pushes: false, named: [url] }],
|
||||
[`gh pr edit ${url}/files --add-label bug`, { urls: 'referenced', pushes: false, named: [url] }],
|
||||
[`gh pr checkout ${url} && git push`, { urls: 'referenced', pushes: true, named: [url] }],
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/** Reference observation through Cordis services without a platform shell or CLI process. */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LlmRuntime, { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { pluginRecordOf, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ShellExecutor from '@deepseek-ai/dsh-shell'
|
||||
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
|
||||
import * as ShellEnv from '@deepseek-ai/dsh-shell-env'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import { bench, pullUrl } from './fixtures.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
const releases: (() => void)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const release of releases.splice(0)) release()
|
||||
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
function output(overrides: Partial<ShellRunResult> = {}): ShellRunResult {
|
||||
return {
|
||||
exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 1_234,
|
||||
stdout: { text: JSON.stringify({ url: pullUrl(9) }), truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function execution(result: () => Promise<ShellRunResult>): ShellExecution {
|
||||
const reader = { readFrom: (from: number) => ({ text: '', nextOffset: from, lossy: false }) }
|
||||
return {
|
||||
status: 'completed', exitCode: 0, signal: null, done: Promise.resolve(),
|
||||
observed: { stdout: reader, stderr: reader },
|
||||
readOutput: () => ({ delta: '', lossy: false }), kill: () => false, result,
|
||||
}
|
||||
}
|
||||
|
||||
class LookupShell extends ShellExecutor {
|
||||
resolve(request: ShellExecRequest): ShellExecSpec {
|
||||
return {
|
||||
...request,
|
||||
workdir: request.workdir ?? process.cwd(), timeoutMs: request.timeoutMs ?? 1_234,
|
||||
onExpiry: 'kill', stdoutMaxBytes: 64_000, sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
async execute(): Promise<ShellExecution> {
|
||||
return execution(async () => output())
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const b = await bench({ discoveryTimeoutMs: 1_234 })
|
||||
const { ctx } = b
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(ShellEnv, { dshHome: process.cwd() })
|
||||
await ctx.plugin(LookupShell)
|
||||
const { agent } = await ctx.agents.create({ sessionId: SessionId('reference-observer') })
|
||||
const controller = new AbortController()
|
||||
const exec: ToolExecution = {
|
||||
name: 'bash', agent,
|
||||
callId: ToolCallId('reference'), rootCallId: ToolCallId('reference'), token: Symbol('reference') as ToolExecutionToken,
|
||||
arguments: { command: 'gh pr view 9 -R octo/demo --json number' }, signal: controller.signal,
|
||||
}
|
||||
const result: ToolExecutionResult = {
|
||||
isError: false, content: [{ type: 'text', text: '{"number":9}' }],
|
||||
value: { kind: 'foreground', cwd: process.cwd(), exitCode: 0, signal: null, timedOut: false, aborted: false },
|
||||
}
|
||||
const records = () => agent.session.snapshotEvents().flatMap((event) => {
|
||||
const record = pluginRecordOf(event)
|
||||
return record?.type === 'plugin:forge-session/binding' ? [record.data] : []
|
||||
})
|
||||
const execute = vi.spyOn(ctx.shell, 'execute')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const emit = (call: ToolExecution = exec, outcome: ToolExecutionResult = result) => { ctx.emit('tools/result', call, outcome) }
|
||||
return { ...b, agent, exec, result, controller, records, execute, warn, emit }
|
||||
}
|
||||
|
||||
describe('PR reference lookup', () => {
|
||||
it('records the resolved URL with the caller cwd and environment, without restoring a dismissed binding', async () => {
|
||||
const f = await fixture()
|
||||
const resolve = vi.spyOn(f.ctx.shell, 'resolve')
|
||||
f.emit()
|
||||
await vi.waitFor(() => { expect(f.records()).toEqual([{ action: 'reference', url: pullUrl(9) }]) })
|
||||
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: 'env gh pr view 9 --repo octo/demo --json url', workdir: process.cwd(), timeoutMs: 1_234,
|
||||
dshEnv: f.ctx.shellEnv.collect(f.exec),
|
||||
}))
|
||||
f.ctx.forgeSession.dismiss(f.agent.session, pullUrl(9))
|
||||
f.emit()
|
||||
expect(f.execute).toHaveBeenCalledTimes(2)
|
||||
const returned = f.execute.mock.results[1]
|
||||
if (returned?.type !== 'return') throw new Error('missing second lookup')
|
||||
await (await returned.value).result()
|
||||
await f.service.dispose()
|
||||
expect(f.records()).toEqual([{ action: 'reference', url: pullUrl(9) }, { action: 'dismiss', url: pullUrl(9) }])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['failed exit', { exitCode: 1 }],
|
||||
['signal', { signal: 'SIGTERM' }],
|
||||
['timeout', { timedOut: true }],
|
||||
['abort result', { aborted: true }],
|
||||
['truncated output', { stdout: { text: '{', truncated: true } }],
|
||||
['invalid JSON', { stdout: { text: '{', truncated: false } }],
|
||||
['missing URL', { stdout: { text: '{}', truncated: false } }],
|
||||
['non-PR URL', { stdout: { text: '{"url":"https://fake.test/octo/demo/issues/9"}', truncated: false } }],
|
||||
] satisfies [string, Partial<ShellRunResult>][])('reports %s without changing bindings or the completed tool result', async (_label, overrides) => {
|
||||
const f = await fixture()
|
||||
f.execute.mockResolvedValue(execution(async () => output(overrides)))
|
||||
f.emit()
|
||||
await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith(expect.stringContaining('PR reference lookup failed')) })
|
||||
expect(f.records()).toEqual([])
|
||||
expect(f.result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a shell preparation rejection that is not an Error', async () => {
|
||||
const f = await fixture()
|
||||
f.execute.mockRejectedValue('unavailable')
|
||||
f.emit()
|
||||
await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith(expect.stringContaining('unavailable')) })
|
||||
expect(f.records()).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['caller', 'service'] as const)('joins pending preparation after %s cancellation and ignores its late result', async (owner) => {
|
||||
const f = await fixture()
|
||||
const pending = Promise.withResolvers<ShellExecution>()
|
||||
const started = Promise.withResolvers<ShellExecSpec>()
|
||||
releases.push(() => { pending.resolve(execution(async () => output())) })
|
||||
f.execute.mockImplementation((spec) => { started.resolve(spec); return pending.promise })
|
||||
f.emit()
|
||||
const spec = await started.promise
|
||||
const aborted = Promise.withResolvers<undefined>()
|
||||
spec.signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
|
||||
if (owner === 'caller') f.controller.abort()
|
||||
let disposed = false
|
||||
const disposal = f.service.dispose().then(() => { disposed = true })
|
||||
await aborted.promise
|
||||
expect(disposed).toBe(false)
|
||||
pending.resolve(execution(async () => output()))
|
||||
await disposal
|
||||
expect(f.records()).toEqual([])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('joins a cancelled preparation rejection without warning', async () => {
|
||||
const f = await fixture()
|
||||
const pending = Promise.withResolvers<ShellExecution>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
releases.push(() => { pending.resolve(execution(async () => output())) })
|
||||
f.execute.mockImplementation(() => { started.resolve(undefined); return pending.promise })
|
||||
f.emit()
|
||||
await started.promise
|
||||
f.controller.abort()
|
||||
pending.reject(new Error('cancelled during preparation'))
|
||||
await f.service.dispose()
|
||||
expect(f.records()).toEqual([])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips unsupported, unsuccessful, already identified, nested, or cancelled calls', async () => {
|
||||
const f = await fixture()
|
||||
f.emit({ ...f.exec, name: 'pwsh' })
|
||||
const { agent: _agent, ...withoutAgent } = f.exec
|
||||
f.emit(withoutAgent)
|
||||
f.emit({ ...f.exec, parent: f.exec.token })
|
||||
f.emit(f.exec, { isError: true, error: { message: 'failed' }, content: [] })
|
||||
f.emit({ ...f.exec, arguments: {} })
|
||||
f.emit(f.exec, { isError: false, value: { kind: 'background' }, content: [] })
|
||||
f.emit({ ...f.exec, arguments: { command: 'gh pr view 9' } }, { ...f.result, content: [{ type: 'reasoning', text: 'thinking' }] })
|
||||
f.emit({ ...f.exec, arguments: { command: 'gh pr view 9 -R fake.test/octo/demo' } })
|
||||
f.emit(f.exec, { ...f.result, content: [{ type: 'text', text: JSON.stringify({ url: pullUrl(9) }) }] })
|
||||
f.controller.abort()
|
||||
f.emit()
|
||||
await f.service.dispose()
|
||||
expect(f.execute).not.toHaveBeenCalled()
|
||||
expect(f.records()).toEqual([])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -454,7 +454,7 @@ describe('restoring removed worktrees', () => {
|
||||
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
|
||||
})
|
||||
|
||||
it('retains a caller cancellation reason when restoration is cancelled before its allocation starts', async () => {
|
||||
it.each([new Error('caller stopped'), 'caller stopped'])('retains cancellation reason %s when restoration is cancelled before its allocation starts', async (reason) => {
|
||||
const root = await repo()
|
||||
ctx = await harness(root)
|
||||
const agent = testAgent(ctx, root)
|
||||
@@ -463,7 +463,7 @@ describe('restoring removed worktrees', () => {
|
||||
const controller = new AbortController()
|
||||
const resolveExecutable = ctx.subprocess.resolveExecutable.bind(ctx.subprocess)
|
||||
const probe = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementation(async (command, env, signal) => {
|
||||
if (command === 'node') controller.abort('caller stopped')
|
||||
if (command === 'node') controller.abort(reason)
|
||||
return resolveExecutable(command, env, signal)
|
||||
})
|
||||
try {
|
||||
@@ -559,13 +559,16 @@ it('does not remove a registration when its directory reappears during restorati
|
||||
const created = await ctx.worktrees.create(agent, { name: 'topic' })
|
||||
await rm(created.path, { recursive: true, force: true })
|
||||
const resolve = ctx.fs.resolve.bind(ctx.fs)
|
||||
const target = await resolve(created.path)
|
||||
let targetReads = 0
|
||||
const probe = vi.spyOn(ctx.fs, 'resolve').mockImplementation(async (path, options) => {
|
||||
if (path === created.path && ++targetReads === 2) await mkdir(created.path)
|
||||
return resolve(path, options)
|
||||
const resolved = await resolve(path, options)
|
||||
if (resolved.targetKey === target.targetKey && ++targetReads === 2) await mkdir(created.path)
|
||||
return resolved
|
||||
})
|
||||
try {
|
||||
await expect(ctx.worktrees.restore(agent, created)).rejects.toThrow('worktree path already exists')
|
||||
expect(targetReads).toBe(2)
|
||||
expect(await git(root, 'worktree', 'list', '--porcelain')).toContain(`worktree ${created.path.split(sep).join('/')}`)
|
||||
} finally { probe.mockRestore() }
|
||||
})
|
||||
@@ -45,7 +45,7 @@ describe('pre-merge check', () => {
|
||||
it('checks the versions before it installs, and installs before the builds', () => {
|
||||
const steps = preMergeSteps('/repo')
|
||||
expect(steps.map(step => step.name)).toEqual(['versions', 'install', 'host contracts', 'client'])
|
||||
expect(steps[0]).toEqual({ name: 'versions', command: process.execPath, args: ['/repo/scripts/verify-version-sync.ts', '/repo'] })
|
||||
expect(steps[0]).toEqual({ name: 'versions', command: process.execPath, args: [join('/repo', 'scripts', 'verify-version-sync.ts'), '/repo'] })
|
||||
expect(steps[1]).toEqual({ name: 'install', command: 'pnpm', args: ['install', '--frozen-lockfile'] })
|
||||
expect(steps[3]?.args).toEqual(['exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'client'])
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user