mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:25:18 +00:00
142 lines
6.1 KiB
TypeScript
142 lines
6.1 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..')
|
|
|
|
/** YAML with comment-only lines stripped — header prose must never satisfy a pin. */
|
|
const readWithoutComments = (relativePath: string) =>
|
|
readFileSync(join(repoRoot, relativePath), 'utf8')
|
|
.split('\n')
|
|
.filter((line) => !line.trimStart().startsWith('#'))
|
|
.join('\n')
|
|
|
|
const workflow = readWithoutComments('.github/workflows/auto-unblock.yml')
|
|
const deployWorkflow = readWithoutComments('.github/workflows/deploy.yml')
|
|
const shell = readFileSync(join(repoRoot, 'scripts', 'auto-unblock.sh'), 'utf8')
|
|
const agent = readFileSync(join(repoRoot, 'scripts', 'auto-unblock-agent.mjs'), 'utf8')
|
|
const orchestrator = readFileSync(join(repoRoot, 'scripts', 'auto-unblock.mjs'), 'utf8')
|
|
|
|
describe('auto-unblock.yml (OPS106 reactor)', () => {
|
|
it('is triggered only by a completed Deploy run', () => {
|
|
expect(workflow).toContain('workflow_run:')
|
|
expect(workflow).toContain("workflows: ['Deploy']")
|
|
expect(workflow).toContain('types: [completed]')
|
|
})
|
|
|
|
it('gates on push to main and conclusion failure', () => {
|
|
expect(workflow).toContain("github.event.workflow_run.conclusion == 'failure'")
|
|
expect(workflow).toContain("github.event.workflow_run.event == 'push'")
|
|
expect(workflow).toContain("github.event.workflow_run.head_branch == 'main'")
|
|
})
|
|
|
|
it('runs on the homeserver runner with the least permissions it needs', () => {
|
|
expect(workflow).toContain('runs-on: [self-hosted, homeserver]')
|
|
expect(workflow).toContain('actions: read')
|
|
expect(workflow).toContain('issues: write')
|
|
})
|
|
|
|
it('checks out without persisted bot credentials and dispatches the shell entrypoint', () => {
|
|
expect(workflow).toContain('persist-credentials: false')
|
|
expect(workflow).toContain('bash scripts/auto-unblock.sh')
|
|
expect(workflow).toContain('AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}')
|
|
expect(workflow).toContain('RUN_ID: ${{ github.event.workflow_run.id }}')
|
|
})
|
|
|
|
it('installs repo deps before the launcher (the detached wrapper loads worktree.mjs)', () => {
|
|
expect(workflow).toContain('pnpm install --frozen-lockfile')
|
|
})
|
|
|
|
it('sets up Node 24 before pnpm (OPS111: homeserver runner defaults to Node 18)', () => {
|
|
// pnpm/action-setup@v6 dies under the homeserver default Node 18
|
|
// (TypeError paths[0] undefined); setup-node@v5 (Node 24) must run first.
|
|
const setupNodeIndex = workflow.indexOf('actions/setup-node@v5')
|
|
const pnpmSetupIndex = workflow.indexOf('pnpm/action-setup')
|
|
const installIndex = workflow.indexOf('pnpm install --frozen-lockfile')
|
|
expect(setupNodeIndex).toBeGreaterThan(-1)
|
|
expect(pnpmSetupIndex).toBeGreaterThan(setupNodeIndex)
|
|
expect(installIndex).toBeGreaterThan(pnpmSetupIndex)
|
|
expect(workflow).toContain('node-version: 24')
|
|
expect(workflow).toContain('package-manager-cache: false')
|
|
})
|
|
|
|
it('keeps the Deploy workflow untouched (no auto-retry, no reactor wiring)', () => {
|
|
expect(deployWorkflow).not.toContain('unblock')
|
|
expect(deployWorkflow).not.toContain('workflow_run')
|
|
})
|
|
|
|
it('pins the deploy job id the reactor keys on (display name would break the gate)', () => {
|
|
// The jobs API reports the job `name` (id when no display name is set);
|
|
// classifyDeployFailure keys on exactly `verify`. A `name:` inside the job
|
|
// block would change that value and silently disable the reactor.
|
|
expect(deployWorkflow).toMatch(/^ verify:$/m)
|
|
const verifyBlock = deployWorkflow
|
|
.split(/\n(?= [A-Za-z0-9_.-]+:)/)
|
|
.find((block) => block.startsWith(' verify:'))
|
|
expect(verifyBlock, 'job verify não encontrado no deploy.yml').toBeDefined()
|
|
expect(verifyBlock).not.toMatch(/^ name:/m)
|
|
})
|
|
|
|
it('the token never enters the claim queue (no ready/in-progress label)', () => {
|
|
expect(orchestrator).not.toContain("'in-progress'")
|
|
expect(orchestrator).not.toContain("'ready'")
|
|
expect(orchestrator).toContain('UNBLOCK_LABEL')
|
|
expect(orchestrator).toContain('UNBLOCK_BLOCKED_LABEL')
|
|
})
|
|
})
|
|
|
|
describe('scripts/auto-unblock.sh (flock handoff)', () => {
|
|
it('is valid bash', () => {
|
|
expect(() =>
|
|
execFileSync('bash', ['-n', join(repoRoot, 'scripts', 'auto-unblock.sh')], {
|
|
stdio: 'pipe',
|
|
}),
|
|
).not.toThrow()
|
|
})
|
|
|
|
it('holds the agent-only lock and hands fd 9 to the orchestrator', () => {
|
|
expect(shell).toContain('exec 9>"$LOCK_FILE"')
|
|
expect(shell).toContain('flock -n 9')
|
|
expect(shell).toContain('exec node scripts/auto-unblock.mjs')
|
|
// The deploy lock must never be reused: the agent cannot block a deploy,
|
|
// and a deploy cannot be mistaken for the agent.
|
|
expect(shell).not.toContain('teqo-deploy.lock')
|
|
expect(shell).toContain('/tmp/teqo-unblock.lock')
|
|
})
|
|
})
|
|
|
|
describe('scripts/auto-unblock-agent.mjs (detached wrapper)', () => {
|
|
it('provisions through the worktree owner in headless mode', () => {
|
|
expect(agent).toContain("'worktree.mjs'")
|
|
expect(agent).toContain("'--headless'")
|
|
expect(agent).toContain("'--directive'")
|
|
})
|
|
|
|
it('enforces the database guard before launching opencode', () => {
|
|
const guardIndex = agent.indexOf('const targets = evaluateDatabaseTargets(')
|
|
const launchIndex = agent.indexOf('const agentRun = runTimed(')
|
|
expect(guardIndex).toBeGreaterThan(-1)
|
|
expect(launchIndex).toBeGreaterThan(guardIndex)
|
|
})
|
|
|
|
it('receives the token number by argv (blocked comment works without state)', () => {
|
|
expect(agent).toContain('flags.token')
|
|
expect(orchestrator).toContain('`--token=${created.number}`')
|
|
})
|
|
|
|
it('injects the PAT only via the process env (no persisted git credential)', () => {
|
|
expect(agent).toContain("'credential.helper'")
|
|
expect(agent).toContain('password=$GITHUB_TOKEN')
|
|
expect(agent).not.toContain('git config')
|
|
})
|
|
|
|
it('caps the opencode run with escalation to SIGKILL', () => {
|
|
expect(agent).toContain("'--kill-after=120'")
|
|
expect(agent).toContain('AGENT_TIMEOUT_SECONDS')
|
|
})
|
|
})
|