Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 24 additions & 42 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,61 +40,27 @@ jobs:
- 'scripts/update-flake.sh'
- '.github/workflows/ci.yml'

test_pr:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build project
run: pnpm run build

- name: Run tests
run: pnpm test

- name: Upload test coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report-pr
path: coverage/
retention-days: 7

test_matrix:
name: Test (${{ matrix.label }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
shell: bash
label: linux-bash
vitest_workers: 4
- os: macos-latest
shell: bash
label: macos-bash
vitest_workers: 4
- os: windows-latest
shell: pwsh
label: windows-pwsh
vitest_workers: 2

defaults:
run:
Expand Down Expand Up @@ -126,16 +92,32 @@ jobs:
run: pnpm run build

- name: Run tests
env:
VITEST_MAX_WORKERS: ${{ matrix.vitest_workers }}
run: pnpm test

- name: Upload test coverage
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: coverage-report-main
name: coverage-report-${{ github.event_name }}
path: coverage/
retention-days: 7

test_pr_required:
name: Test
runs-on: ubuntu-latest
needs: [test_matrix]
if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group')
steps:
- name: Verify matrix tests passed
run: |
if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then
echo "Matrix test job failed"
exit 1
fi
echo "All matrix tests passed!"

lint:
name: Lint & Type Check
runs-on: ubuntu-latest
Expand Down Expand Up @@ -288,13 +270,13 @@ jobs:
required-checks-pr:
name: All checks passed
runs-on: ubuntu-latest
needs: [test_pr, lint, nix-flake-validate]
needs: [test_matrix, lint, nix-flake-validate]
if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group')
steps:
- name: Verify all checks passed
run: |
if [[ "${{ needs.test_pr.result }}" != "success" ]]; then
echo "Test job failed"
if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then
echo "Matrix test job failed"
exit 1
fi
if [[ "${{ needs.lint.result }}" != "success" ]]; then
Expand Down
9 changes: 6 additions & 3 deletions test/cli-e2e/capstone-journeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import * as path from 'node:path';
import { getGlobalDataDir, registerStore } from '../../src/core/index.js';
import { runCLI } from '../helpers/run-cli.js';
import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

const JOURNEY_TIMEOUT_MS = 30_000;

/**
* Capstone persona journeys (6.1). Journey 1 (fresh team) lives in
Expand All @@ -31,7 +34,7 @@ describe('capstone persona journeys (6.1)', () => {
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

it('journey 2 — layered flow: app-repo agent discovers, cites, designs locally', async () => {
Expand Down Expand Up @@ -97,7 +100,7 @@ describe('capstone persona journeys (6.1)', () => {
// The store stayed read-only context throughout.
const storeChanges = fs.readdirSync(path.join(storeRoot, 'openspec', 'changes'));
expect(storeChanges.filter((name) => name !== 'archive' && name !== '.gitkeep')).toEqual([]);
});
}, JOURNEY_TIMEOUT_MS);

it('journey 3 — externalized planning: pointer repo runs the lifecycle without --store', async () => {
const storeRoot = path.join(tempDir, 'team-planning');
Expand Down Expand Up @@ -174,5 +177,5 @@ describe('capstone persona journeys (6.1)', () => {

// The code repo never grew planning state.
expect(fs.readdirSync(path.join(codeRepo, 'openspec'))).toEqual(['config.yaml']);
});
}, JOURNEY_TIMEOUT_MS);
});
10 changes: 6 additions & 4 deletions test/cli-e2e/store-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'path';
import { tmpdir } from 'os';
import { promisify } from 'util';
import { runCLI } from '../helpers/run-cli.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

const execFileAsync = promisify(execFile);

Expand All @@ -18,6 +19,7 @@ const execFileAsync = promisify(execFile);
*/

const STORE_ID = 'team-context';
const JOURNEY_TIMEOUT_MS = 60_000;

let base: string;
let storeRoot: string;
Expand Down Expand Up @@ -189,7 +191,7 @@ beforeAll(async () => {
}, 120_000);

afterAll(async () => {
await fs.rm(base, { recursive: true, force: true });
cleanupTempPath(base);
});

describe('standalone store lifecycle journey', () => {
Expand Down Expand Up @@ -337,7 +339,7 @@ describe('standalone store lifecycle journey', () => {
path.join(storeRoot, 'openspec', 'changes', 'archive')
);
expect(archiveEntries.some((entry) => entry.endsWith(`-${changeId}`))).toBe(true);
});
}, JOURNEY_TIMEOUT_MS);

it('machine A: the project repo is byte-identical after the lifecycle', async () => {
const after = await snapshotDirectory(projectDir);
Expand Down Expand Up @@ -391,7 +393,7 @@ describe('standalone store lifecycle journey', () => {
);
expect(shownSpec.exitCode).toBe(0);
expect(shownSpec.stdout).toContain('billing SHALL work');
});
}, JOURNEY_TIMEOUT_MS);

it('machine B: completes its own change through archive in the clone', async () => {
const changeId = 'add-invoicing';
Expand Down Expand Up @@ -450,7 +452,7 @@ describe('standalone store lifecycle journey', () => {
expect(failedApply.exitCode).not.toBe(0);
expect(failedApply.stderr).toContain(`Using OpenSpec root: ${STORE_ID}`);
expect(failedApply.stderr).toContain(`openspec new change <name> --store ${STORE_ID}`);
});
}, JOURNEY_TIMEOUT_MS);

it('end state is just normal OpenSpec files in both checkouts', async () => {
for (const root of [storeRoot, cloneRoot]) {
Expand Down
7 changes: 5 additions & 2 deletions test/commands/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { getGlobalDataDir, registerStore } from '../../src/core/index.js';
import { runCLI, type RunCLIResult } from '../helpers/run-cli.js';
import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js';
import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

const CONTEXT_MATRIX_TIMEOUT_MS = 30_000;

describe('openspec context (4.1)', () => {
let tempDir: string;
Expand Down Expand Up @@ -41,7 +44,7 @@ describe('openspec context (4.1)', () => {
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

function parseJson(result: RunCLIResult): any {
Expand Down Expand Up @@ -193,7 +196,7 @@ describe('openspec context (4.1)', () => {
);
expect(jsonBadDir.exitCode).toBe(1);
expect(JSON.parse(jsonBadDir.stdout).status[0].code).toBe('context_output_dir_missing');
});
}, CONTEXT_MATRIX_TIMEOUT_MS);

it('is read-only except the requested file and fails with the null shape', async () => {
const rootBefore = snapshot(storeRoot);
Expand Down
3 changes: 2 additions & 1 deletion test/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getGlobalDataDir, registerStore } from '../../src/core/index.js';
import { runCLI, type RunCLIResult } from '../helpers/run-cli.js';
import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js';
import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

describe('openspec doctor (3.6)', () => {
let tempDir: string;
Expand All @@ -30,7 +31,7 @@ describe('openspec doctor (3.6)', () => {
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

function parseJson(result: RunCLIResult): any {
Expand Down
7 changes: 5 additions & 2 deletions test/commands/legacy-groups-removed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import * as path from 'node:path';
import { getGlobalDataDir, registerStore } from '../../src/core/index.js';
import { runCLI } from '../helpers/run-cli.js';
import { createHealthyOpenSpecRoot } from '../helpers/store-git.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

const SURVIVING_COMMANDS_TIMEOUT_MS = 30_000;

describe('legacy command groups are removed', () => {
let tempDir: string;
Expand All @@ -24,7 +27,7 @@ describe('legacy command groups are removed', () => {
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

function snapshotDirectory(root: string): Map<string, string> {
Expand Down Expand Up @@ -139,7 +142,7 @@ describe('legacy command groups are removed', () => {

expect(snapshotDirectory(path.join(storeRoot, 'initiatives'))).toEqual(initiativeBefore);
expect(snapshotDirectory(path.join(projectDir, '.openspec-workspace'))).toEqual(viewBefore);
});
}, SURVIVING_COMMANDS_TIMEOUT_MS);

it('tolerates legacy initiative metadata without re-emitting it', async () => {
const projectDir = path.join(tempDir, 'legacy-project');
Expand Down
3 changes: 2 additions & 1 deletion test/commands/store-git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../../src/core/index.js';
import { runCLI, type RunCLIResult } from '../helpers/run-cli.js';
import { createHealthyOpenSpecRoot, isolatedGitEnv } from '../helpers/store-git.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

vi.mock('@inquirer/prompts', () => ({
input: vi.fn(),
Expand Down Expand Up @@ -82,7 +83,7 @@ describe('store git lifecycle', () => {
consoleLogSpy?.mockRestore();
consoleErrorSpy?.mockRestore();
vi.clearAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

function mkdir(relativePath: string): string {
Expand Down
6 changes: 4 additions & 2 deletions test/commands/store-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
} from '../../src/core/index.js';
import { runCLI, type RunCLIResult } from '../helpers/run-cli.js';
import { createHealthyOpenSpecRoot, isolatedGitEnv } from '../helpers/store-git.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

const TEST_NET_URL = 'https://192.0.2.1/acme/team-context.git';
const GIT_JOURNEY_TIMEOUT_MS = 60_000;

describe('store canonical remote (3.3)', () => {
let tempDir: string;
Expand All @@ -33,7 +35,7 @@ describe('store canonical remote (3.3)', () => {
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

function git(cwd: string, ...args: string[]): string {
Expand Down Expand Up @@ -419,7 +421,7 @@ describe('store canonical remote (3.3)', () => {
const resolvedEntry = parseJson(resolved).references[0];
expect(resolvedEntry.status).toEqual([]);
expect(resolvedEntry.root).toBe(fs.realpathSync.native(expectedCheckout));
});
}, GIT_JOURNEY_TIMEOUT_MS);
});

describe('doctor and resolution', () => {
Expand Down
3 changes: 2 additions & 1 deletion test/commands/store-root-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '../../src/core/index.js';
import { writeStoreMetadataState } from '../../src/core/store/foundation.js';
import { runCLI, type RunCLIResult } from '../helpers/run-cli.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

const VALID_DELTA_SPEC = `## ADDED Requirements

Expand Down Expand Up @@ -69,7 +70,7 @@ describe('store root selection for normal commands', () => {
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

function createOpenSpecRoot(rootDir: string): void {
Expand Down
11 changes: 6 additions & 5 deletions test/commands/workset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { runCLI, type RunCLIResult } from '../helpers/run-cli.js';
import { createFakeTool, envWithFakeTools, readLaunchLog } from '../helpers/fake-tool.js';
import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

describe('openspec workset (7.1)', () => {
let tempDir: string;
Expand Down Expand Up @@ -56,7 +57,7 @@ describe('openspec workset (7.1)', () => {

afterEach(() => {
delete process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS;
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

function parseJson(result: RunCLIResult): any {
Expand Down Expand Up @@ -469,7 +470,7 @@ describe('openspec workset (7.1)', () => {
it('skips a missing member and falls through to the next primary', async () => {
await createPlatform(['--tool', 'claude']);
const fakeClaude = createFakeTool(tempDir, 'claude');
fs.rmSync(memberB, { recursive: true, force: true });
cleanupTempPath(memberB);

const result = await runCLI(['workset', 'open', 'platform'], {
cwd: tempDir,
Expand All @@ -494,7 +495,7 @@ describe('openspec workset (7.1)', () => {

// Primary missing: the next surviving member becomes cwd, and
// the reassignment is noted in the skip-line style.
fs.rmSync(memberA, { recursive: true, force: true });
cleanupTempPath(memberA);
const second = await runCLI(['workset', 'open', 'platform'], {
cwd: tempDir,
env: envWithFakeTools(env, [fakeClaude]),
Expand All @@ -508,7 +509,7 @@ describe('openspec workset (7.1)', () => {
);

// No member survives: a typed failure.
fs.rmSync(memberC, { recursive: true, force: true });
cleanupTempPath(memberC);
const third = await runCLI(['workset', 'open', 'platform'], {
cwd: tempDir,
env: envWithFakeTools(env, [fakeClaude]),
Expand Down Expand Up @@ -871,7 +872,7 @@ describe('interactive compose cancellation (in-process)', () => {
restoreTTY?.();
process.env = originalEnv;
process.exitCode = originalExitCode;
fs.rmSync(tempDir, { recursive: true, force: true });
cleanupTempPath(tempDir);
});

function exitPromptError(): Error {
Expand Down
Loading
Loading