@mytechtoday/augment-sdd 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +20 -0
- package/out/commands/executeBeadsBatch.js +165 -0
- package/out/commands/executeBeadsBatch.js.map +1 -0
- package/out/commands/fullPipeline.js +129 -0
- package/out/commands/fullPipeline.js.map +1 -0
- package/out/commands/generateBeads.js +148 -0
- package/out/commands/generateBeads.js.map +1 -0
- package/out/commands/generateOpenSpec.js +241 -0
- package/out/commands/generateOpenSpec.js.map +1 -0
- package/out/dashboard/DashboardPanel.js +171 -0
- package/out/dashboard/DashboardPanel.js.map +1 -0
- package/out/extension.js +96 -0
- package/out/extension.js.map +1 -0
- package/out/parsers/parseBeadUpdates.js +28 -0
- package/out/parsers/parseBeadUpdates.js.map +1 -0
- package/out/parsers/parseTasksMarkdown.js +49 -0
- package/out/parsers/parseTasksMarkdown.js.map +1 -0
- package/out/test/integration/executeBeadsBatch.test.js +155 -0
- package/out/test/integration/executeBeadsBatch.test.js.map +1 -0
- package/out/test/integration/generateOpenSpec.test.js +154 -0
- package/out/test/integration/generateOpenSpec.test.js.map +1 -0
- package/out/test/runTest.js +47 -0
- package/out/test/runTest.js.map +1 -0
- package/out/test/suite/index.js +74 -0
- package/out/test/suite/index.js.map +1 -0
- package/out/test/unit/parseBeadUpdates.test.js +73 -0
- package/out/test/unit/parseBeadUpdates.test.js.map +1 -0
- package/out/test/unit/parseTasksMarkdown.test.js +69 -0
- package/out/test/unit/parseTasksMarkdown.test.js.map +1 -0
- package/out/test/unit/runCli.test.js +113 -0
- package/out/test/unit/runCli.test.js.map +1 -0
- package/out/utils/detectCli.js +30 -0
- package/out/utils/detectCli.js.map +1 -0
- package/out/utils/getConfig.js +60 -0
- package/out/utils/getConfig.js.map +1 -0
- package/out/utils/logger.js +30 -0
- package/out/utils/logger.js.map +1 -0
- package/out/utils/runCli.js +122 -0
- package/out/utils/runCli.js.map +1 -0
- package/package.json +111 -0
- package/src/commands/executeBeadsBatch.ts +153 -0
- package/src/commands/fullPipeline.ts +120 -0
- package/src/commands/generateBeads.ts +127 -0
- package/src/commands/generateOpenSpec.ts +227 -0
- package/src/dashboard/DashboardPanel.ts +168 -0
- package/src/extension.ts +77 -0
- package/src/parsers/parseBeadUpdates.ts +26 -0
- package/src/parsers/parseTasksMarkdown.ts +61 -0
- package/src/test/integration/executeBeadsBatch.test.ts +129 -0
- package/src/test/integration/generateOpenSpec.test.ts +129 -0
- package/src/test/runTest.ts +15 -0
- package/src/test/suite/index.ts +37 -0
- package/src/test/unit/parseBeadUpdates.test.ts +48 -0
- package/src/test/unit/parseTasksMarkdown.test.ts +41 -0
- package/src/test/unit/runCli.test.ts +109 -0
- package/src/utils/detectCli.ts +28 -0
- package/src/utils/getConfig.ts +25 -0
- package/src/utils/logger.ts +42 -0
- package/src/utils/runCli.ts +102 -0
- package/tsconfig.json +18 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { initLogger, log, revealOutputChannel } from './utils/logger';
|
|
3
|
+
import { detectCli } from './utils/detectCli';
|
|
4
|
+
import { generateOpenSpec } from './commands/generateOpenSpec';
|
|
5
|
+
import { generateBeads } from './commands/generateBeads';
|
|
6
|
+
import { executeBeadsBatch } from './commands/executeBeadsBatch';
|
|
7
|
+
import { fullPipeline } from './commands/fullPipeline';
|
|
8
|
+
|
|
9
|
+
/** CLI definitions: name on PATH and the npm package to install if missing. */
|
|
10
|
+
const REQUIRED_CLIS: { name: string; pkg: string }[] = [
|
|
11
|
+
{ name: 'auggie', pkg: 'auggie' },
|
|
12
|
+
{ name: 'openspec', pkg: 'openspec' },
|
|
13
|
+
{ name: 'bd', pkg: '@beads/cli' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run CLI presence checks on activation. For each missing CLI, show an error
|
|
18
|
+
* message with the exact `npm install -g <pkg>` command and a "View Logs" button.
|
|
19
|
+
*/
|
|
20
|
+
async function checkRequiredClis(): Promise<void> {
|
|
21
|
+
for (const { name, pkg } of REQUIRED_CLIS) {
|
|
22
|
+
const found = await detectCli(name);
|
|
23
|
+
if (!found) {
|
|
24
|
+
log(`CLI not found on PATH: ${name} (install with: npm install -g ${pkg})`);
|
|
25
|
+
const action = await vscode.window.showErrorMessage(
|
|
26
|
+
`Augment SDD: Required CLI "${name}" not found. Install it with: npm install -g ${pkg}`,
|
|
27
|
+
'View Logs'
|
|
28
|
+
);
|
|
29
|
+
if (action === 'View Logs') {
|
|
30
|
+
revealOutputChannel();
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
log(`CLI detected: ${name}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function activate(context: vscode.ExtensionContext): void {
|
|
39
|
+
// Create the Output Channel first and initialise the shared logger so all
|
|
40
|
+
// subsequent code (including commands) can call log() / revealOutputChannel().
|
|
41
|
+
const outputChannel = vscode.window.createOutputChannel('Augment SDD');
|
|
42
|
+
initLogger(outputChannel);
|
|
43
|
+
context.subscriptions.push(outputChannel);
|
|
44
|
+
|
|
45
|
+
log('Augment SDD extension activating…');
|
|
46
|
+
|
|
47
|
+
// Run CLI detection (non-blocking — failures are surfaced as error messages).
|
|
48
|
+
checkRequiredClis().catch(err => {
|
|
49
|
+
log(`CLI detection error: ${String(err)}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Register commands (implementations wired in subsequent beads).
|
|
53
|
+
context.subscriptions.push(
|
|
54
|
+
vscode.commands.registerCommand('augmentSdd.generateOpenSpec', async () => {
|
|
55
|
+
await generateOpenSpec();
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
vscode.commands.registerCommand('augmentSdd.generateBeads', async () => {
|
|
59
|
+
await generateBeads();
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
vscode.commands.registerCommand('augmentSdd.executeBeadsBatch', async () => {
|
|
63
|
+
await executeBeadsBatch();
|
|
64
|
+
}),
|
|
65
|
+
|
|
66
|
+
vscode.commands.registerCommand('augmentSdd.fullPipeline', async () => {
|
|
67
|
+
await fullPipeline();
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
log('Augment SDD extension activated.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function deactivate(): void {
|
|
75
|
+
// Nothing to clean up; subscriptions are disposed automatically.
|
|
76
|
+
}
|
|
77
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseBeadUpdates — Sentinel line parser for Bead 7.
|
|
3
|
+
*
|
|
4
|
+
* Extracts all lines that match the Auggie completion sentinel format
|
|
5
|
+
* from a free-form text response. Each matched line can be executed
|
|
6
|
+
* directly as a CLI command via runCli.
|
|
7
|
+
*
|
|
8
|
+
* Design D4: Sentinel-based parsing is format-agnostic — it works
|
|
9
|
+
* regardless of how much prose Auggie wraps around the commands.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract all `bd update <ID> --status done` lines from Auggie output.
|
|
14
|
+
*
|
|
15
|
+
* @param output Raw text returned by `auggie --print --quiet`.
|
|
16
|
+
* @returns Array of matched command strings (may be empty).
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* parseBeadUpdates('done\nbd update bd-abc --status done\nmore text')
|
|
20
|
+
* // => ['bd update bd-abc --status done']
|
|
21
|
+
*/
|
|
22
|
+
export function parseBeadUpdates(output: string): string[] {
|
|
23
|
+
const matches = [...output.matchAll(/^bd update \S+ --status done$/gm)];
|
|
24
|
+
return matches.map(m => m[0]);
|
|
25
|
+
}
|
|
26
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseTasksMarkdown — heading-based task extractor for Auggie-generated tasks.md.
|
|
3
|
+
*
|
|
4
|
+
* Design (D3): Uses a deterministic heading-based regex (`/^#{1,3}\s+(.+)/gm`)
|
|
5
|
+
* to extract task titles and their body text. No external parser dependency is
|
|
6
|
+
* required; Auggie's structured output is regular enough for this approach.
|
|
7
|
+
*
|
|
8
|
+
* Each H1–H3 heading becomes a Bead title; the body text between that heading
|
|
9
|
+
* and the next heading (or EOF) becomes the Bead description.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single extracted task ready to be created as a Bead. */
|
|
13
|
+
export interface BeadTask {
|
|
14
|
+
/** The heading text, trimmed (becomes the `bd create` title). */
|
|
15
|
+
title: string;
|
|
16
|
+
/** All text between this heading and the next, trimmed (becomes the description). */
|
|
17
|
+
description: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse `content` (the raw text of a `tasks.md` file) into an ordered array of
|
|
22
|
+
* `{ title, description }` objects.
|
|
23
|
+
*
|
|
24
|
+
* - Recognises H1, H2, and H3 headings (`#`, `##`, `###`).
|
|
25
|
+
* - The heading line itself is the title; everything after it up to the next
|
|
26
|
+
* heading (or end-of-file) is the description (whitespace-trimmed).
|
|
27
|
+
* - Returns an empty array when no headings are found.
|
|
28
|
+
*
|
|
29
|
+
* @param content Raw markdown text of a `tasks.md` file.
|
|
30
|
+
*/
|
|
31
|
+
export function parseTasksMarkdown(content: string): BeadTask[] {
|
|
32
|
+
const headingRegex = /^#{1,3}\s+(.+)/gm;
|
|
33
|
+
const tasks: BeadTask[] = [];
|
|
34
|
+
|
|
35
|
+
// Collect all heading positions and titles in one pass.
|
|
36
|
+
const positions: Array<{ index: number; title: string }> = [];
|
|
37
|
+
let match: RegExpExecArray | null;
|
|
38
|
+
|
|
39
|
+
while ((match = headingRegex.exec(content)) !== null) {
|
|
40
|
+
positions.push({ index: match.index, title: match[1].trim() });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// For each heading, extract the body as everything between its start and the
|
|
44
|
+
// start of the next heading (or end-of-file).
|
|
45
|
+
for (let i = 0; i < positions.length; i++) {
|
|
46
|
+
const { index, title } = positions[i];
|
|
47
|
+
const nextIndex = i + 1 < positions.length ? positions[i + 1].index : content.length;
|
|
48
|
+
|
|
49
|
+
// Slice the segment, skip the heading line itself, then trim whitespace.
|
|
50
|
+
const segment = content.slice(index, nextIndex);
|
|
51
|
+
const firstNewline = segment.indexOf('\n');
|
|
52
|
+
const description = firstNewline >= 0
|
|
53
|
+
? segment.slice(firstNewline + 1).trim()
|
|
54
|
+
: '';
|
|
55
|
+
|
|
56
|
+
tasks.push({ title, description });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return tasks;
|
|
60
|
+
}
|
|
61
|
+
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for executeBeadsBatch command.
|
|
3
|
+
* IT-3: 3 beads ready — all 3 bd update commands executed successfully.
|
|
4
|
+
* IT-4: Partial failure — second bd update fails; others succeed, warning shown.
|
|
5
|
+
*
|
|
6
|
+
* Strategy: stub cp.spawn via sinon so no real CLIs are invoked.
|
|
7
|
+
*/
|
|
8
|
+
import * as assert from 'assert';
|
|
9
|
+
import * as cp from 'child_process';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as sinon from 'sinon';
|
|
14
|
+
import * as vscode from 'vscode';
|
|
15
|
+
import { EventEmitter } from 'events';
|
|
16
|
+
import { executeBeadsBatch } from '../../commands/executeBeadsBatch';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Fake process factory
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function fakeProc(opts: { stdout?: string; stderr?: string; code?: number }): cp.ChildProcess {
|
|
22
|
+
const stdoutEE = new EventEmitter();
|
|
23
|
+
const stderrEE = new EventEmitter();
|
|
24
|
+
const procEE = new EventEmitter();
|
|
25
|
+
const proc = {
|
|
26
|
+
stdout: stdoutEE,
|
|
27
|
+
stderr: stderrEE,
|
|
28
|
+
kill: () => { /* noop */ },
|
|
29
|
+
on: procEE.on.bind(procEE),
|
|
30
|
+
once: procEE.once.bind(procEE),
|
|
31
|
+
removeListener: procEE.removeListener.bind(procEE),
|
|
32
|
+
emit: procEE.emit.bind(procEE),
|
|
33
|
+
};
|
|
34
|
+
setImmediate(() => {
|
|
35
|
+
if (opts.stdout) { stdoutEE.emit('data', Buffer.from(opts.stdout)); }
|
|
36
|
+
if (opts.stderr) { stderrEE.emit('data', Buffer.from(opts.stderr)); }
|
|
37
|
+
procEE.emit('close', opts.code ?? 0);
|
|
38
|
+
});
|
|
39
|
+
return proc as unknown as cp.ChildProcess;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
const FAKE_BEADS = JSON.stringify([
|
|
46
|
+
{ id: 'bd-a1', title: 'Bead A1', description: 'Do A1' },
|
|
47
|
+
{ id: 'bd-b2', title: 'Bead B2', description: 'Do B2' },
|
|
48
|
+
{ id: 'bd-b3', title: 'Bead B3', description: 'Do B3' },
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const AUGGIE_SENTINELS =
|
|
52
|
+
'bd update bd-a1 --status done\n' +
|
|
53
|
+
'bd update bd-b2 --status done\n' +
|
|
54
|
+
'bd update bd-b3 --status done';
|
|
55
|
+
|
|
56
|
+
function stubWorkspace(dir: string): () => void {
|
|
57
|
+
const orig = Object.getOwnPropertyDescriptor(vscode.workspace, 'workspaceFolders');
|
|
58
|
+
Object.defineProperty(vscode.workspace, 'workspaceFolders', {
|
|
59
|
+
configurable: true,
|
|
60
|
+
get: () => [{ uri: vscode.Uri.file(dir), name: 'test', index: 0 }],
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
if (orig) { Object.defineProperty(vscode.workspace, 'workspaceFolders', orig); }
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Tests
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
suite('executeBeadsBatch — integration tests', () => {
|
|
71
|
+
let tmpDir: string;
|
|
72
|
+
let restoreWF: () => void;
|
|
73
|
+
let spawnStub: sinon.SinonStub;
|
|
74
|
+
let infoStub: sinon.SinonStub;
|
|
75
|
+
let warnStub: sinon.SinonStub;
|
|
76
|
+
let errStub: sinon.SinonStub;
|
|
77
|
+
|
|
78
|
+
setup(() => {
|
|
79
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'augsdd-batch-'));
|
|
80
|
+
restoreWF = stubWorkspace(tmpDir);
|
|
81
|
+
spawnStub = sinon.stub(cp, 'spawn') as sinon.SinonStub;
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
infoStub = sinon.stub(vscode.window, 'showInformationMessage' as any).resolves(undefined);
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
warnStub = sinon.stub(vscode.window, 'showWarningMessage' as any).resolves(undefined);
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
errStub = sinon.stub(vscode.window, 'showErrorMessage' as any).resolves(undefined);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
teardown(() => {
|
|
91
|
+
sinon.restore();
|
|
92
|
+
restoreWF();
|
|
93
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('IT-3: 3 beads ready — all 3 bd update commands executed', async () => {
|
|
97
|
+
// Call order: bd ready, auggie, bd update ×3
|
|
98
|
+
spawnStub.onCall(0).returns(fakeProc({ stdout: FAKE_BEADS, code: 0 }));
|
|
99
|
+
spawnStub.onCall(1).returns(fakeProc({ stdout: AUGGIE_SENTINELS, code: 0 }));
|
|
100
|
+
spawnStub.onCall(2).returns(fakeProc({ stdout: '', code: 0 }));
|
|
101
|
+
spawnStub.onCall(3).returns(fakeProc({ stdout: '', code: 0 }));
|
|
102
|
+
spawnStub.onCall(4).returns(fakeProc({ stdout: '', code: 0 }));
|
|
103
|
+
|
|
104
|
+
await executeBeadsBatch();
|
|
105
|
+
|
|
106
|
+
assert.strictEqual(spawnStub.callCount, 5, 'spawn should be called 5 times total');
|
|
107
|
+
assert.ok(infoStub.called, 'success info message should be shown');
|
|
108
|
+
assert.ok(!warnStub.called, 'no warning should be shown on clean success');
|
|
109
|
+
assert.ok(!errStub.called, 'no error should be shown on clean success');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('IT-4: partial failure — second bd update fails, warning shown', async () => {
|
|
113
|
+
// bd ready → auggie → bd-a1 ok → bd-b2 FAIL → bd-b3 ok
|
|
114
|
+
spawnStub.onCall(0).returns(fakeProc({ stdout: FAKE_BEADS, code: 0 }));
|
|
115
|
+
spawnStub.onCall(1).returns(fakeProc({ stdout: AUGGIE_SENTINELS, code: 0 }));
|
|
116
|
+
spawnStub.onCall(2).returns(fakeProc({ stdout: '', code: 0 }));
|
|
117
|
+
spawnStub.onCall(3).returns(fakeProc({ stdout: '', stderr: 'not found', code: 1 }));
|
|
118
|
+
spawnStub.onCall(4).returns(fakeProc({ stdout: '', code: 0 }));
|
|
119
|
+
|
|
120
|
+
await executeBeadsBatch();
|
|
121
|
+
|
|
122
|
+
assert.ok(warnStub.called, 'warning message should be shown on partial failure');
|
|
123
|
+
assert.ok(!errStub.called, 'no hard error should be shown on partial failure');
|
|
124
|
+
// Verify all 5 spawns happened (pipeline did not abort)
|
|
125
|
+
assert.strictEqual(spawnStub.callCount, 5, 'all 3 bd update attempts should be made');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
});
|
|
129
|
+
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for generateOpenSpec command.
|
|
3
|
+
* IT-1: Happy path — proposal.md written, validate passes.
|
|
4
|
+
* IT-2: Auggie returns empty output on both attempts → showErrorMessage called.
|
|
5
|
+
*
|
|
6
|
+
* Strategy: stub cp.spawn via sinon so no real CLIs are invoked.
|
|
7
|
+
* A temp directory is created on each test and wired as the workspace root.
|
|
8
|
+
*/
|
|
9
|
+
import * as assert from 'assert';
|
|
10
|
+
import * as cp from 'child_process';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import * as sinon from 'sinon';
|
|
15
|
+
import * as vscode from 'vscode';
|
|
16
|
+
import { EventEmitter } from 'events';
|
|
17
|
+
import { generateOpenSpec } from '../../commands/generateOpenSpec';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Fake process factory (mirrors the one in runCli.test.ts)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function fakeProc(opts: { stdout?: string; code?: number }): cp.ChildProcess {
|
|
23
|
+
const stdoutEE = new EventEmitter();
|
|
24
|
+
const procEE = new EventEmitter();
|
|
25
|
+
const proc = {
|
|
26
|
+
stdout: stdoutEE,
|
|
27
|
+
stderr: new EventEmitter(),
|
|
28
|
+
kill: () => { /* noop */ },
|
|
29
|
+
on: procEE.on.bind(procEE),
|
|
30
|
+
once: procEE.once.bind(procEE),
|
|
31
|
+
removeListener: procEE.removeListener.bind(procEE),
|
|
32
|
+
emit: procEE.emit.bind(procEE),
|
|
33
|
+
};
|
|
34
|
+
setImmediate(() => {
|
|
35
|
+
if (opts.stdout) { stdoutEE.emit('data', Buffer.from(opts.stdout)); }
|
|
36
|
+
procEE.emit('close', opts.code ?? 0);
|
|
37
|
+
});
|
|
38
|
+
return proc as unknown as cp.ChildProcess;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Helpers
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
function makeTempWorkspace(): string {
|
|
45
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'augsdd-it-'));
|
|
46
|
+
// .jira folder with a single markdown ticket for auto-detection
|
|
47
|
+
const jiraDir = path.join(dir, '.jira');
|
|
48
|
+
fs.mkdirSync(jiraDir);
|
|
49
|
+
fs.writeFileSync(path.join(jiraDir, 'TICKET-1.md'), '# Ticket\nDetails');
|
|
50
|
+
// openspec/changes/<id> must exist so findNewestChangeDir works
|
|
51
|
+
const changeDir = path.join(dir, 'openspec', 'changes', 'test-change');
|
|
52
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stubWorkspace(dir: string): () => void {
|
|
57
|
+
const orig = Object.getOwnPropertyDescriptor(vscode.workspace, 'workspaceFolders');
|
|
58
|
+
Object.defineProperty(vscode.workspace, 'workspaceFolders', {
|
|
59
|
+
configurable: true,
|
|
60
|
+
get: () => [{ uri: vscode.Uri.file(dir), name: 'test', index: 0 }],
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
if (orig) { Object.defineProperty(vscode.workspace, 'workspaceFolders', orig); }
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Tests
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
suite('generateOpenSpec — integration tests', () => {
|
|
71
|
+
let tmpDir = '';
|
|
72
|
+
let restoreWF: () => void;
|
|
73
|
+
let spawnStub: sinon.SinonStub;
|
|
74
|
+
let infoStub: sinon.SinonStub;
|
|
75
|
+
let errStub: sinon.SinonStub;
|
|
76
|
+
|
|
77
|
+
setup(() => {
|
|
78
|
+
tmpDir = makeTempWorkspace();
|
|
79
|
+
restoreWF = stubWorkspace(tmpDir);
|
|
80
|
+
spawnStub = sinon.stub(cp, 'spawn') as sinon.SinonStub;
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
infoStub = sinon.stub(vscode.window, 'showInformationMessage' as any).resolves(undefined);
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
errStub = sinon.stub(vscode.window, 'showErrorMessage' as any).resolves(undefined);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
teardown(() => {
|
|
88
|
+
sinon.restore();
|
|
89
|
+
restoreWF();
|
|
90
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('IT-1: happy path — proposal.md written, validate exits 0', async () => {
|
|
94
|
+
// Spawn sequence: init → instructions → auggie → validate
|
|
95
|
+
spawnStub.onCall(0).returns(fakeProc({ stdout: '', code: 0 }));
|
|
96
|
+
spawnStub.onCall(1).returns(fakeProc({ stdout: '{"template":"Write a proposal."}', code: 0 }));
|
|
97
|
+
spawnStub.onCall(2).returns(fakeProc({ stdout: '# Proposal\nContent here.', code: 0 }));
|
|
98
|
+
spawnStub.onCall(3).returns(fakeProc({ stdout: 'OK', code: 0 }));
|
|
99
|
+
|
|
100
|
+
await generateOpenSpec();
|
|
101
|
+
|
|
102
|
+
// proposal.md must have been written
|
|
103
|
+
const proposalPath = path.join(tmpDir, 'openspec', 'changes', 'test-change', 'proposal.md');
|
|
104
|
+
assert.ok(fs.existsSync(proposalPath), 'proposal.md should exist');
|
|
105
|
+
const content = fs.readFileSync(proposalPath, 'utf8');
|
|
106
|
+
assert.ok(content.includes('Proposal'), 'proposal.md should contain Auggie output');
|
|
107
|
+
|
|
108
|
+
// Success info message shown, no error
|
|
109
|
+
assert.ok(infoStub.called, 'showInformationMessage should be called on success');
|
|
110
|
+
assert.ok(!errStub.called, 'showErrorMessage should NOT be called on success');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('IT-2: Auggie returns empty on both attempts → showErrorMessage called', async () => {
|
|
114
|
+
// init → instructions → auggie (empty) → auggie retry (empty)
|
|
115
|
+
spawnStub.onCall(0).returns(fakeProc({ stdout: '', code: 0 }));
|
|
116
|
+
spawnStub.onCall(1).returns(fakeProc({ stdout: '{"template":"template"}', code: 0 }));
|
|
117
|
+
spawnStub.onCall(2).returns(fakeProc({ stdout: '', code: 0 }));
|
|
118
|
+
spawnStub.onCall(3).returns(fakeProc({ stdout: '', code: 0 }));
|
|
119
|
+
|
|
120
|
+
await generateOpenSpec();
|
|
121
|
+
|
|
122
|
+
// Error message must be shown; no proposal file written
|
|
123
|
+
assert.ok(errStub.called, 'showErrorMessage should be called after two empty Auggie attempts');
|
|
124
|
+
const proposalPath = path.join(tmpDir, 'openspec', 'changes', 'test-change', 'proposal.md');
|
|
125
|
+
assert.ok(!fs.existsSync(proposalPath), 'proposal.md should NOT be written on failure');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
});
|
|
129
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { runTests } from '@vscode/test-electron';
|
|
3
|
+
|
|
4
|
+
async function main(): Promise<void> {
|
|
5
|
+
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
|
|
6
|
+
const extensionTestsPath = path.resolve(__dirname, './suite/index');
|
|
7
|
+
|
|
8
|
+
await runTests({ extensionDevelopmentPath, extensionTestsPath });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
main().catch(err => {
|
|
12
|
+
console.error('Failed to run tests:', err);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import Mocha from 'mocha';
|
|
4
|
+
|
|
5
|
+
/** Recursively collect all *.test.js files under `dir`. */
|
|
6
|
+
function findTestFiles(dir: string): string[] {
|
|
7
|
+
const results: string[] = [];
|
|
8
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
9
|
+
const full = path.join(dir, entry.name);
|
|
10
|
+
if (entry.isDirectory()) {
|
|
11
|
+
results.push(...findTestFiles(full));
|
|
12
|
+
} else if (entry.isFile() && entry.name.endsWith('.test.js')) {
|
|
13
|
+
results.push(full);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function run(): Promise<void> {
|
|
20
|
+
const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 10_000 });
|
|
21
|
+
|
|
22
|
+
const testsRoot = path.resolve(__dirname, '..');
|
|
23
|
+
for (const f of findTestFiles(testsRoot)) {
|
|
24
|
+
mocha.addFile(f);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return new Promise<void>((resolve, reject) => {
|
|
28
|
+
mocha.run(failures => {
|
|
29
|
+
if (failures > 0) {
|
|
30
|
+
reject(new Error(`${failures} test(s) failed.`));
|
|
31
|
+
} else {
|
|
32
|
+
resolve();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for parseBeadUpdates.ts
|
|
3
|
+
* UT-5: parses valid sentinel lines from Auggie output
|
|
4
|
+
* UT-6: returns empty array when no sentinel lines found
|
|
5
|
+
*/
|
|
6
|
+
import * as assert from 'assert';
|
|
7
|
+
import { parseBeadUpdates } from '../../parsers/parseBeadUpdates';
|
|
8
|
+
|
|
9
|
+
suite('parseBeadUpdates — unit tests', () => {
|
|
10
|
+
|
|
11
|
+
test('UT-5: parses bd update lines from Auggie output', () => {
|
|
12
|
+
const output =
|
|
13
|
+
'some text\n' +
|
|
14
|
+
'bd update bd-a1 --status done\n' +
|
|
15
|
+
'more text\n' +
|
|
16
|
+
'bd update bd-b2 --status done';
|
|
17
|
+
|
|
18
|
+
const lines = parseBeadUpdates(output);
|
|
19
|
+
assert.deepStrictEqual(lines, [
|
|
20
|
+
'bd update bd-a1 --status done',
|
|
21
|
+
'bd update bd-b2 --status done',
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('UT-6: returns empty array when no sentinel lines found', () => {
|
|
26
|
+
const lines = parseBeadUpdates('Auggie did not finish');
|
|
27
|
+
assert.deepStrictEqual(lines, []);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('ignores lines that are partial / malformed sentinels', () => {
|
|
31
|
+
const output =
|
|
32
|
+
'bd update --status done\n' + // missing id
|
|
33
|
+
' bd update bd-x1 --status done\n' + // leading spaces (not at start)
|
|
34
|
+
'bd update bd-x2 --status pending\n' + // wrong status
|
|
35
|
+
'bd update bd-x3 --status done'; // valid
|
|
36
|
+
|
|
37
|
+
const lines = parseBeadUpdates(output);
|
|
38
|
+
assert.deepStrictEqual(lines, ['bd update bd-x3 --status done']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('handles CRLF line endings', () => {
|
|
42
|
+
const output = 'preamble\r\nbd update bd-crlf --status done\r\npostamble';
|
|
43
|
+
const lines = parseBeadUpdates(output);
|
|
44
|
+
assert.deepStrictEqual(lines, ['bd update bd-crlf --status done']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
});
|
|
48
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for parseTasksMarkdown.ts
|
|
3
|
+
* UT-4: Headings extracted as task titles with descriptions
|
|
4
|
+
*/
|
|
5
|
+
import * as assert from 'assert';
|
|
6
|
+
import { parseTasksMarkdown, BeadTask } from '../../parsers/parseTasksMarkdown';
|
|
7
|
+
|
|
8
|
+
suite('parseTasksMarkdown — unit tests', () => {
|
|
9
|
+
|
|
10
|
+
test('UT-4: extracts headings as task titles', () => {
|
|
11
|
+
const md = '## Task 1\nDo this\n## Task 2\nDo that';
|
|
12
|
+
const tasks: BeadTask[] = parseTasksMarkdown(md);
|
|
13
|
+
assert.deepStrictEqual(tasks.map(t => t.title), ['Task 1', 'Task 2']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('extracts body text as task description', () => {
|
|
17
|
+
const md = '## Alpha\nFirst line\nSecond line\n## Beta\nOther';
|
|
18
|
+
const tasks = parseTasksMarkdown(md);
|
|
19
|
+
assert.strictEqual(tasks[0].description, 'First line\nSecond line');
|
|
20
|
+
assert.strictEqual(tasks[1].description, 'Other');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('returns empty array when no headings are found', () => {
|
|
24
|
+
const tasks = parseTasksMarkdown('No headings here.');
|
|
25
|
+
assert.deepStrictEqual(tasks, []);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('handles H1, H2, and H3 headings', () => {
|
|
29
|
+
const md = '# One\na\n## Two\nb\n### Three\nc';
|
|
30
|
+
const tasks = parseTasksMarkdown(md);
|
|
31
|
+
assert.deepStrictEqual(tasks.map(t => t.title), ['One', 'Two', 'Three']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('trims extra whitespace from titles', () => {
|
|
35
|
+
const md = '## Spaced Title \nBody';
|
|
36
|
+
const tasks = parseTasksMarkdown(md);
|
|
37
|
+
assert.strictEqual(tasks[0].title, 'Spaced Title');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
});
|
|
41
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for runCli.ts
|
|
3
|
+
* UT-1: resolves with trimmed stdout on exit code 0
|
|
4
|
+
* UT-2: rejects on non-zero exit code
|
|
5
|
+
* UT-3: rejects on timeout
|
|
6
|
+
*/
|
|
7
|
+
import * as assert from 'assert';
|
|
8
|
+
import * as cp from 'child_process';
|
|
9
|
+
import * as sinon from 'sinon';
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
import { runCli } from '../../utils/runCli';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Fake ChildProcess factory
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
interface FakeProcOpts {
|
|
18
|
+
stdout?: string;
|
|
19
|
+
stderr?: string;
|
|
20
|
+
/** Exit code to emit on 'close'. If undefined, defaults to 0. */
|
|
21
|
+
code?: number | null;
|
|
22
|
+
/**
|
|
23
|
+
* If set, the process deliberately hangs for this many ms before emitting
|
|
24
|
+
* 'close'. Use with a runCli timeoutMs smaller than hangMs to test timeout.
|
|
25
|
+
*/
|
|
26
|
+
hangMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fakeProc(opts: FakeProcOpts): cp.ChildProcess {
|
|
30
|
+
const stdoutEE = new EventEmitter();
|
|
31
|
+
const stderrEE = new EventEmitter();
|
|
32
|
+
const procEE = new EventEmitter();
|
|
33
|
+
|
|
34
|
+
// Minimal object that satisfies what runCli actually accesses.
|
|
35
|
+
const proc = {
|
|
36
|
+
stdout: stdoutEE,
|
|
37
|
+
stderr: stderrEE,
|
|
38
|
+
kill: () => { /* noop – timeout test relies on this */ },
|
|
39
|
+
on: procEE.on.bind(procEE),
|
|
40
|
+
once: procEE.once.bind(procEE),
|
|
41
|
+
removeListener: procEE.removeListener.bind(procEE),
|
|
42
|
+
emit: procEE.emit.bind(procEE),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
setImmediate(() => {
|
|
46
|
+
if (opts.stdout) { stdoutEE.emit('data', Buffer.from(opts.stdout)); }
|
|
47
|
+
if (opts.stderr) { stderrEE.emit('data', Buffer.from(opts.stderr)); }
|
|
48
|
+
|
|
49
|
+
if (opts.hangMs === undefined) {
|
|
50
|
+
procEE.emit('close', opts.code ?? 0);
|
|
51
|
+
} else {
|
|
52
|
+
// Deliberately hang so the runCli timer can fire first.
|
|
53
|
+
setTimeout(() => procEE.emit('close', null), opts.hangMs);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return proc as unknown as cp.ChildProcess;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Tests
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
suite('runCli — unit tests', () => {
|
|
65
|
+
|
|
66
|
+
test('UT-1: resolves with trimmed stdout on exit code 0', async () => {
|
|
67
|
+
const stub = sinon.stub(cp, 'spawn').returns(
|
|
68
|
+
fakeProc({ stdout: 'hello\n', code: 0 })
|
|
69
|
+
);
|
|
70
|
+
try {
|
|
71
|
+
const result = await runCli('echo', ['hello'], '/tmp');
|
|
72
|
+
assert.strictEqual(result, 'hello');
|
|
73
|
+
} finally {
|
|
74
|
+
stub.restore();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('UT-2: rejects on non-zero exit code', async () => {
|
|
79
|
+
const stub = sinon.stub(cp, 'spawn').returns(
|
|
80
|
+
fakeProc({ stdout: '', stderr: 'err', code: 1 })
|
|
81
|
+
);
|
|
82
|
+
try {
|
|
83
|
+
await assert.rejects(
|
|
84
|
+
runCli('false', [], '/tmp'),
|
|
85
|
+
/failed with exit code 1/
|
|
86
|
+
);
|
|
87
|
+
} finally {
|
|
88
|
+
stub.restore();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('UT-3: rejects on timeout', async () => {
|
|
93
|
+
// hangMs (5 000) >> timeoutMs (100) → timer fires first, process is killed,
|
|
94
|
+
// promise rejects with a message containing "timed out".
|
|
95
|
+
const stub = sinon.stub(cp, 'spawn').returns(
|
|
96
|
+
fakeProc({ hangMs: 5_000, code: null })
|
|
97
|
+
);
|
|
98
|
+
try {
|
|
99
|
+
await assert.rejects(
|
|
100
|
+
runCli('sleep', ['5'], '/tmp', 100),
|
|
101
|
+
/timed out/i
|
|
102
|
+
);
|
|
103
|
+
} finally {
|
|
104
|
+
stub.restore();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
});
|
|
109
|
+
|