@studio-foundation/cli 0.3.0-beta.1
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/ARCHITECTURE.md +40 -0
- package/LICENSE +663 -0
- package/README.md +165 -0
- package/dist/commands/api.d.ts +16 -0
- package/dist/commands/api.d.ts.map +1 -0
- package/dist/commands/api.js +118 -0
- package/dist/commands/api.js.map +1 -0
- package/dist/commands/config.d.ts +32 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +390 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/init.d.ts +93 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +728 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +22 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/integrations.d.ts +14 -0
- package/dist/commands/integrations.d.ts.map +1 -0
- package/dist/commands/integrations.js +282 -0
- package/dist/commands/integrations.js.map +1 -0
- package/dist/commands/list.d.ts +9 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +161 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/logs.d.ts +7 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +98 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/ollama.d.ts +6 -0
- package/dist/commands/ollama.d.ts.map +1 -0
- package/dist/commands/ollama.js +161 -0
- package/dist/commands/ollama.js.map +1 -0
- package/dist/commands/project.d.ts +38 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +137 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/registry/audit.d.ts +13 -0
- package/dist/commands/registry/audit.d.ts.map +1 -0
- package/dist/commands/registry/audit.js +65 -0
- package/dist/commands/registry/audit.js.map +1 -0
- package/dist/commands/registry/index.d.ts +3 -0
- package/dist/commands/registry/index.d.ts.map +1 -0
- package/dist/commands/registry/index.js +53 -0
- package/dist/commands/registry/index.js.map +1 -0
- package/dist/commands/registry/install.d.ts +17 -0
- package/dist/commands/registry/install.d.ts.map +1 -0
- package/dist/commands/registry/install.js +153 -0
- package/dist/commands/registry/install.js.map +1 -0
- package/dist/commands/registry/publish.d.ts +8 -0
- package/dist/commands/registry/publish.d.ts.map +1 -0
- package/dist/commands/registry/publish.js +94 -0
- package/dist/commands/registry/publish.js.map +1 -0
- package/dist/commands/registry/remove.d.ts +8 -0
- package/dist/commands/registry/remove.d.ts.map +1 -0
- package/dist/commands/registry/remove.js +82 -0
- package/dist/commands/registry/remove.js.map +1 -0
- package/dist/commands/registry/search.d.ts +9 -0
- package/dist/commands/registry/search.d.ts.map +1 -0
- package/dist/commands/registry/search.js +68 -0
- package/dist/commands/registry/search.js.map +1 -0
- package/dist/commands/registry/sync.d.ts +8 -0
- package/dist/commands/registry/sync.d.ts.map +1 -0
- package/dist/commands/registry/sync.js +19 -0
- package/dist/commands/registry/sync.js.map +1 -0
- package/dist/commands/registry/update.d.ts +15 -0
- package/dist/commands/registry/update.d.ts.map +1 -0
- package/dist/commands/registry/update.js +48 -0
- package/dist/commands/registry/update.js.map +1 -0
- package/dist/commands/replay.d.ts +36 -0
- package/dist/commands/replay.d.ts.map +1 -0
- package/dist/commands/replay.js +453 -0
- package/dist/commands/replay.js.map +1 -0
- package/dist/commands/run.d.ts +18 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +406 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +144 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/template/index.d.ts +2 -0
- package/dist/commands/template/index.d.ts.map +1 -0
- package/dist/commands/template/index.js +57 -0
- package/dist/commands/template/index.js.map +1 -0
- package/dist/commands/template/validate.d.ts +8 -0
- package/dist/commands/template/validate.d.ts.map +1 -0
- package/dist/commands/template/validate.js +135 -0
- package/dist/commands/template/validate.js.map +1 -0
- package/dist/commands/templates.d.ts +14 -0
- package/dist/commands/templates.d.ts.map +1 -0
- package/dist/commands/templates.js +86 -0
- package/dist/commands/templates.js.map +1 -0
- package/dist/commands/tools.d.ts +12 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +200 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/commands/users.d.ts +4 -0
- package/dist/commands/users.d.ts.map +1 -0
- package/dist/commands/users.js +123 -0
- package/dist/commands/users.js.map +1 -0
- package/dist/commands/validate.d.ts +2 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +39 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +178 -0
- package/dist/index.js.map +1 -0
- package/dist/models-cache.d.ts +13 -0
- package/dist/models-cache.d.ts.map +1 -0
- package/dist/models-cache.js +106 -0
- package/dist/models-cache.js.map +1 -0
- package/dist/output/file-changes.d.ts +15 -0
- package/dist/output/file-changes.d.ts.map +1 -0
- package/dist/output/file-changes.js +77 -0
- package/dist/output/file-changes.js.map +1 -0
- package/dist/output/formatter.d.ts +6 -0
- package/dist/output/formatter.d.ts.map +1 -0
- package/dist/output/formatter.js +70 -0
- package/dist/output/formatter.js.map +1 -0
- package/dist/output/formatters.d.ts +42 -0
- package/dist/output/formatters.d.ts.map +1 -0
- package/dist/output/formatters.js +280 -0
- package/dist/output/formatters.js.map +1 -0
- package/dist/output/logger.d.ts +3 -0
- package/dist/output/logger.d.ts.map +1 -0
- package/dist/output/logger.js +15 -0
- package/dist/output/logger.js.map +1 -0
- package/dist/output/parallel-progress.d.ts +33 -0
- package/dist/output/parallel-progress.d.ts.map +1 -0
- package/dist/output/parallel-progress.js +85 -0
- package/dist/output/parallel-progress.js.map +1 -0
- package/dist/output/progress.d.ts +32 -0
- package/dist/output/progress.d.ts.map +1 -0
- package/dist/output/progress.js +393 -0
- package/dist/output/progress.js.map +1 -0
- package/dist/provider-validator.d.ts +15 -0
- package/dist/provider-validator.d.ts.map +1 -0
- package/dist/provider-validator.js +85 -0
- package/dist/provider-validator.js.map +1 -0
- package/dist/registry/cache.d.ts +10 -0
- package/dist/registry/cache.d.ts.map +1 -0
- package/dist/registry/cache.js +36 -0
- package/dist/registry/cache.js.map +1 -0
- package/dist/registry/client.d.ts +19 -0
- package/dist/registry/client.d.ts.map +1 -0
- package/dist/registry/client.js +63 -0
- package/dist/registry/client.js.map +1 -0
- package/dist/registry/lockfile.d.ts +17 -0
- package/dist/registry/lockfile.d.ts.map +1 -0
- package/dist/registry/lockfile.js +61 -0
- package/dist/registry/lockfile.js.map +1 -0
- package/dist/registry/resolver.d.ts +13 -0
- package/dist/registry/resolver.d.ts.map +1 -0
- package/dist/registry/resolver.js +79 -0
- package/dist/registry/resolver.js.map +1 -0
- package/dist/registry/types.d.ts +59 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +14 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/run-logger.d.ts +8 -0
- package/dist/run-logger.d.ts.map +1 -0
- package/dist/run-logger.js +53 -0
- package/dist/run-logger.js.map +1 -0
- package/dist/run-store-factory.d.ts +9 -0
- package/dist/run-store-factory.d.ts.map +1 -0
- package/dist/run-store-factory.js +26 -0
- package/dist/run-store-factory.js.map +1 -0
- package/dist/studio-dir.d.ts +7 -0
- package/dist/studio-dir.d.ts.map +1 -0
- package/dist/studio-dir.js +27 -0
- package/dist/studio-dir.js.map +1 -0
- package/dist/utils/input-wizard.d.ts +4 -0
- package/dist/utils/input-wizard.d.ts.map +1 -0
- package/dist/utils/input-wizard.js +56 -0
- package/dist/utils/input-wizard.js.map +1 -0
- package/dist/utils/placeholders.d.ts +6 -0
- package/dist/utils/placeholders.d.ts.map +1 -0
- package/dist/utils/placeholders.js +12 -0
- package/dist/utils/placeholders.js.map +1 -0
- package/package.json +43 -0
- package/src/commands/api.ts +126 -0
- package/src/commands/config.ts +433 -0
- package/src/commands/init.ts +879 -0
- package/src/commands/install.ts +23 -0
- package/src/commands/integrations.ts +332 -0
- package/src/commands/list.ts +197 -0
- package/src/commands/logs.ts +119 -0
- package/src/commands/ollama.ts +168 -0
- package/src/commands/project.ts +167 -0
- package/src/commands/registry/audit.ts +87 -0
- package/src/commands/registry/index.ts +63 -0
- package/src/commands/registry/install.ts +222 -0
- package/src/commands/registry/publish.ts +112 -0
- package/src/commands/registry/remove.ts +89 -0
- package/src/commands/registry/search.ts +93 -0
- package/src/commands/registry/sync.ts +24 -0
- package/src/commands/registry/update.ts +63 -0
- package/src/commands/replay.ts +559 -0
- package/src/commands/run.ts +454 -0
- package/src/commands/status.ts +163 -0
- package/src/commands/template/index.ts +59 -0
- package/src/commands/template/validate.ts +175 -0
- package/src/commands/templates.ts +99 -0
- package/src/commands/tools.ts +227 -0
- package/src/commands/users.ts +127 -0
- package/src/commands/validate.ts +46 -0
- package/src/config.ts +101 -0
- package/src/index.ts +201 -0
- package/src/models-cache.ts +127 -0
- package/src/output/file-changes.ts +97 -0
- package/src/output/formatter.ts +85 -0
- package/src/output/formatters.ts +303 -0
- package/src/output/logger.ts +18 -0
- package/src/output/parallel-progress.ts +103 -0
- package/src/output/progress.ts +414 -0
- package/src/provider-validator.ts +109 -0
- package/src/registry/cache.ts +46 -0
- package/src/registry/client.ts +84 -0
- package/src/registry/lockfile.ts +68 -0
- package/src/registry/resolver.ts +113 -0
- package/src/registry/types.ts +66 -0
- package/src/run-logger.ts +66 -0
- package/src/run-store-factory.ts +30 -0
- package/src/studio-dir.ts +28 -0
- package/src/utils/input-wizard.ts +73 -0
- package/src/utils/placeholders.ts +10 -0
- package/templates/.studiorc.yaml +16 -0
- package/templates/projects/blank/metadata.json +7 -0
- package/templates/studio-config.yaml +14 -0
- package/tests/__stubs__/studio-runner.ts +104 -0
- package/tests/commands/api.test.ts +110 -0
- package/tests/commands/config.test.ts +221 -0
- package/tests/commands/init.test.ts +919 -0
- package/tests/commands/install.test.ts +52 -0
- package/tests/commands/integrations.test.ts +158 -0
- package/tests/commands/ollama.test.ts +139 -0
- package/tests/commands/project.test.ts +179 -0
- package/tests/commands/registry/audit.test.ts +56 -0
- package/tests/commands/registry/install.test.ts +200 -0
- package/tests/commands/registry/publish.test.ts +56 -0
- package/tests/commands/registry/remove.test.ts +103 -0
- package/tests/commands/registry/search.test.ts +44 -0
- package/tests/commands/registry/sync.test.ts +37 -0
- package/tests/commands/registry/update.test.ts +62 -0
- package/tests/commands/replay.test.ts +283 -0
- package/tests/commands/template/validate.test.ts +150 -0
- package/tests/commands/templates.test.ts +42 -0
- package/tests/commands/tools.test.ts +106 -0
- package/tests/config.test.ts +142 -0
- package/tests/formatter.test.ts +158 -0
- package/tests/integration/sigint.test.ts +188 -0
- package/tests/models-cache.test.ts +250 -0
- package/tests/output/file-changes.test.ts +178 -0
- package/tests/output/formatters.test.ts +448 -0
- package/tests/output/progress-spinner.test.ts +232 -0
- package/tests/output/progress-timer.test.ts +230 -0
- package/tests/provider-validator.test.ts +182 -0
- package/tests/registry/cache.test.ts +66 -0
- package/tests/registry/client.test.ts +70 -0
- package/tests/registry/lockfile.test.ts +87 -0
- package/tests/registry/resolver.test.ts +122 -0
- package/tests/run-logger-events.test.ts +326 -0
- package/tests/run-logger.test.ts +64 -0
- package/tests/run-store-factory.test.ts +51 -0
- package/tests/studio-dir.test.ts +31 -0
- package/tests/utils/input-wizard.test.ts +153 -0
- package/tests/utils/placeholders.test.ts +36 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { findJsonlFile, mapJsonlLineToEvent, parseJsonlForResume, resolveStageFromPipeline } from '../../src/commands/replay.js';
|
|
5
|
+
import type { PipelineDefinition } from '@studio-foundation/contracts';
|
|
6
|
+
|
|
7
|
+
const TMP = resolve('/tmp', '.studio-replay-test');
|
|
8
|
+
const RUNS_DIR = resolve(TMP, '.studio/runs');
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mkdirSync(RUNS_DIR, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(TMP, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('findJsonlFile', () => {
|
|
19
|
+
it('finds a JSONL file matching full 8-char run-id', () => {
|
|
20
|
+
const filename = '2026-02-22T14h35m-feature-builder-abc12345.jsonl';
|
|
21
|
+
writeFileSync(resolve(RUNS_DIR, filename), '');
|
|
22
|
+
const result = findJsonlFile(RUNS_DIR, 'abc12345');
|
|
23
|
+
expect(result).toBe(resolve(RUNS_DIR, filename));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('finds a JSONL file matching partial run-id (4 chars)', () => {
|
|
27
|
+
const filename = '2026-02-22T14h35m-feature-builder-abc12345.jsonl';
|
|
28
|
+
writeFileSync(resolve(RUNS_DIR, filename), '');
|
|
29
|
+
const result = findJsonlFile(RUNS_DIR, 'abc1');
|
|
30
|
+
expect(result).toBe(resolve(RUNS_DIR, filename));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws if no matching file found', () => {
|
|
34
|
+
writeFileSync(resolve(RUNS_DIR, '2026-02-22T14h35m-pipe-zzz99999.jsonl'), '');
|
|
35
|
+
expect(() => findJsonlFile(RUNS_DIR, 'abc1')).toThrow(/No run log found/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('throws if multiple files match an ambiguous prefix', () => {
|
|
39
|
+
writeFileSync(resolve(RUNS_DIR, '2026-02-22T14h35m-pipe1-abc12345.jsonl'), '');
|
|
40
|
+
writeFileSync(resolve(RUNS_DIR, '2026-02-22T15h00m-pipe2-abc12399.jsonl'), '');
|
|
41
|
+
expect(() => findJsonlFile(RUNS_DIR, 'abc12')).toThrow(/Multiple/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('strips dashes from UUID-style run-id before matching', () => {
|
|
45
|
+
const filename = '2026-02-22T14h35m-feature-builder-abc12345.jsonl';
|
|
46
|
+
writeFileSync(resolve(RUNS_DIR, filename), '');
|
|
47
|
+
const result = findJsonlFile(RUNS_DIR, 'abc1-2345');
|
|
48
|
+
expect(result).toBe(resolve(RUNS_DIR, filename));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('mapJsonlLineToEvent', () => {
|
|
53
|
+
it('maps pipeline_start', () => {
|
|
54
|
+
const line = { event: 'pipeline_start', pipeline: 'feature-builder', run_id: 'abc12345' };
|
|
55
|
+
const result = mapJsonlLineToEvent(line);
|
|
56
|
+
expect(result).toEqual({
|
|
57
|
+
handler: 'onPipelineStart',
|
|
58
|
+
payload: { pipeline_name: 'feature-builder', run_id: 'abc12345' },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('maps pipeline_complete', () => {
|
|
63
|
+
const line = {
|
|
64
|
+
event: 'pipeline_complete', pipeline_name: 'feature-builder', run_id: 'abc12345',
|
|
65
|
+
status: 'success', duration_ms: 5000, total_tokens: 1000, total_tool_calls: 3,
|
|
66
|
+
};
|
|
67
|
+
const result = mapJsonlLineToEvent(line);
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
handler: 'onPipelineComplete',
|
|
70
|
+
payload: {
|
|
71
|
+
pipeline_name: 'feature-builder', run_id: 'abc12345',
|
|
72
|
+
status: 'success', duration_ms: 5000, total_tokens: 1000, total_tool_calls: 3,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('maps stage_start (stage → stage_name)', () => {
|
|
78
|
+
const line = { event: 'stage_start', stage: 'code-generation', stage_index: 0, total_stages: 3 };
|
|
79
|
+
const result = mapJsonlLineToEvent(line);
|
|
80
|
+
expect(result).toEqual({
|
|
81
|
+
handler: 'onStageStart',
|
|
82
|
+
payload: { stage_name: 'code-generation', stage_index: 0, total_stages: 3 },
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('maps stage_complete with token remapping', () => {
|
|
87
|
+
const line = {
|
|
88
|
+
event: 'stage_complete', stage: 'code-generation', status: 'success',
|
|
89
|
+
stage_index: 0, total_stages: 3,
|
|
90
|
+
attempts: 1, duration_ms: 2000,
|
|
91
|
+
tokens: { prompt: 500, completion: 200, total: 700 },
|
|
92
|
+
tool_calls: [{ name: 'repo_manager-write_file', arguments_summary: 'path=src/foo.ts' }],
|
|
93
|
+
output: { summary: 'done' },
|
|
94
|
+
};
|
|
95
|
+
const result = mapJsonlLineToEvent(line);
|
|
96
|
+
expect(result?.payload).toMatchObject({
|
|
97
|
+
stage_name: 'code-generation',
|
|
98
|
+
token_usage: { prompt_tokens: 500, completion_tokens: 200, total_tokens: 700 },
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('maps stage_retry', () => {
|
|
103
|
+
const line = {
|
|
104
|
+
event: 'stage_retry', stage: 'code-generation', attempt: 2, max_attempts: 3,
|
|
105
|
+
failures: ['missing field: summary'], tool_calls_count: 1,
|
|
106
|
+
};
|
|
107
|
+
const result = mapJsonlLineToEvent(line);
|
|
108
|
+
expect(result).toEqual({
|
|
109
|
+
handler: 'onTaskRetry',
|
|
110
|
+
payload: {
|
|
111
|
+
stage: 'code-generation', attempt: 2, max_attempts: 3,
|
|
112
|
+
failures: ['missing field: summary'], tool_calls_count: 1,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('maps group events (group → group_name)', () => {
|
|
118
|
+
const line = { event: 'group_start', group: 'impl-review', max_iterations: 3 };
|
|
119
|
+
const result = mapJsonlLineToEvent(line);
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
handler: 'onGroupStart',
|
|
122
|
+
payload: { group_name: 'impl-review', max_iterations: 3 },
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('maps tool_call_start', () => {
|
|
127
|
+
const line = { event: 'tool_call_start', tool: 'repo_manager-write_file', params: { path: 'src/foo.ts' } };
|
|
128
|
+
const result = mapJsonlLineToEvent(line);
|
|
129
|
+
expect(result).toEqual({
|
|
130
|
+
handler: 'onToolCallStart',
|
|
131
|
+
payload: { tool: 'repo_manager-write_file', params: { path: 'src/foo.ts' }, timestamp: 0 },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('maps tool_call_complete', () => {
|
|
136
|
+
const line = { event: 'tool_call_complete', tool: 'repo_manager-write_file', result: { written: true }, duration_ms: 50 };
|
|
137
|
+
const result = mapJsonlLineToEvent(line);
|
|
138
|
+
expect(result).toEqual({
|
|
139
|
+
handler: 'onToolCallComplete',
|
|
140
|
+
payload: { tool: 'repo_manager-write_file', result: { written: true }, duration_ms: 50, timestamp: 0 },
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns null for unknown event types', () => {
|
|
145
|
+
const line = { event: 'unknown_thing', data: 123 };
|
|
146
|
+
const result = mapJsonlLineToEvent(line);
|
|
147
|
+
expect(result).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('parseJsonlForResume', () => {
|
|
152
|
+
it('extracts input and stage outputs from JSONL', () => {
|
|
153
|
+
const lines = [
|
|
154
|
+
JSON.stringify({ event: 'pipeline_start', pipeline: 'my-pipe', run_id: 'abc12345', input: { x: 1 } }),
|
|
155
|
+
JSON.stringify({ event: 'stage_complete', stage: 'stage-a', run_id: 'abc12345', status: 'success', attempts: 1, duration_ms: 100, output: { result: 'a-result' }, tool_calls: [] }),
|
|
156
|
+
JSON.stringify({ event: 'stage_complete', stage: 'stage-b', run_id: 'abc12345', status: 'success', attempts: 1, duration_ms: 200, output: { result: 'b-result' }, tool_calls: [{ id: '1', name: 'repo_manager-read_file', arguments: { path: 'foo.ts' } }] }),
|
|
157
|
+
].join('\n');
|
|
158
|
+
|
|
159
|
+
const result = parseJsonlForResume(lines);
|
|
160
|
+
|
|
161
|
+
expect(result.pipelineInput).toEqual({ x: 1 });
|
|
162
|
+
expect(result.stageOutputs.get('stage-a')).toEqual({ result: 'a-result' });
|
|
163
|
+
expect(result.stageOutputs.get('stage-b')).toEqual({ result: 'b-result' });
|
|
164
|
+
expect(result.stageToolResults.get('stage-b')).toHaveLength(1);
|
|
165
|
+
expect(result.stageToolResults.get('stage-b')![0]!.name).toBe('repo_manager-read_file');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns empty maps if no stage_complete events', () => {
|
|
169
|
+
const lines = JSON.stringify({ event: 'pipeline_start', pipeline: 'x', run_id: 'abc', input: {} });
|
|
170
|
+
const result = parseJsonlForResume(lines);
|
|
171
|
+
expect(result.stageOutputs.size).toBe(0);
|
|
172
|
+
expect(result.stageToolResults.size).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('skips stages with no output', () => {
|
|
176
|
+
const lines = JSON.stringify({ event: 'stage_complete', stage: 'stage-a', run_id: 'abc', status: 'success', attempts: 1, duration_ms: 100 });
|
|
177
|
+
const result = parseJsonlForResume(lines);
|
|
178
|
+
expect(result.stageOutputs.has('stage-a')).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('resolveStageFromPipeline', () => {
|
|
183
|
+
const pipeline: PipelineDefinition = {
|
|
184
|
+
name: 'test',
|
|
185
|
+
description: 'test',
|
|
186
|
+
version: 1,
|
|
187
|
+
stages: [
|
|
188
|
+
{ name: 'stage-a', executor: 'script', script: 'x.py', runtime: 'shell' },
|
|
189
|
+
{
|
|
190
|
+
group: 'my-group',
|
|
191
|
+
max_iterations: 3,
|
|
192
|
+
stages: [
|
|
193
|
+
{ name: 'stage-b', executor: 'script', script: 'x.py', runtime: 'shell' },
|
|
194
|
+
{ name: 'stage-c', executor: 'script', script: 'x.py', runtime: 'shell' },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
it('resolves a stage name by exact match', () => {
|
|
201
|
+
expect(resolveStageFromPipeline('stage-b', pipeline)).toBe('stage-b');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('resolves a stage by 0-based leaf index', () => {
|
|
205
|
+
// leaf order: stage-a(0), stage-b(1), stage-c(2)
|
|
206
|
+
expect(resolveStageFromPipeline('0', pipeline)).toBe('stage-a');
|
|
207
|
+
expect(resolveStageFromPipeline('1', pipeline)).toBe('stage-b');
|
|
208
|
+
expect(resolveStageFromPipeline('2', pipeline)).toBe('stage-c');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('throws if name not found', () => {
|
|
212
|
+
expect(() => resolveStageFromPipeline('nonexistent', pipeline)).toThrow(/not found/i);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('throws if index out of bounds', () => {
|
|
216
|
+
expect(() => resolveStageFromPipeline('5', pipeline)).toThrow(/out of bounds/i);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('index 0 returns the first stage name', () => {
|
|
220
|
+
expect(resolveStageFromPipeline('0', pipeline)).toBe('stage-a');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('replay integration — full JSONL file', () => {
|
|
225
|
+
it('maps a complete pipeline run through all events without errors', () => {
|
|
226
|
+
const jsonlLines = [
|
|
227
|
+
{ event: 'pipeline_start', pipeline: 'test-pipe', run_id: 'aabb1122', ts: '2026-02-22T14:00:00Z' },
|
|
228
|
+
{ event: 'stage_start', stage: 'analysis', stage_index: 0, total_stages: 2, ts: '2026-02-22T14:00:01Z' },
|
|
229
|
+
{ event: 'tool_call_start', tool: 'repo_manager-read_file', params: { path: 'README.md' }, ts: '2026-02-22T14:00:02Z' },
|
|
230
|
+
{ event: 'tool_call_complete', tool: 'repo_manager-read_file', result: { content: '# Hello' }, duration_ms: 100, ts: '2026-02-22T14:00:02Z' },
|
|
231
|
+
{ event: 'stage_complete', stage: 'analysis', stage_index: 0, total_stages: 2, status: 'success', attempts: 1, duration_ms: 2000, tokens: { prompt: 500, completion: 200, total: 700 }, output: { summary: 'analyzed' }, ts: '2026-02-22T14:00:03Z' },
|
|
232
|
+
{ event: 'stage_start', stage: 'code-generation', stage_index: 1, total_stages: 2, ts: '2026-02-22T14:00:04Z' },
|
|
233
|
+
{ event: 'stage_retry', stage: 'code-generation', attempt: 2, max_attempts: 3, failures: ['missing field'], ts: '2026-02-22T14:00:05Z' },
|
|
234
|
+
{ event: 'stage_complete', stage: 'code-generation', stage_index: 1, total_stages: 2, status: 'success', attempts: 2, duration_ms: 4000, tokens: { prompt: 800, completion: 400, total: 1200 }, output: { summary: 'generated' }, ts: '2026-02-22T14:00:08Z' },
|
|
235
|
+
{ event: 'pipeline_complete', pipeline_name: 'test-pipe', run_id: 'aabb1122', status: 'success', duration_ms: 8000, total_tokens: 1900, total_tool_calls: 1, ts: '2026-02-22T14:00:08Z' },
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const mapped = jsonlLines.map((line) => mapJsonlLineToEvent(line));
|
|
239
|
+
expect(mapped.every((m) => m !== null)).toBe(true);
|
|
240
|
+
expect(mapped.map((m) => m!.handler)).toEqual([
|
|
241
|
+
'onPipelineStart',
|
|
242
|
+
'onStageStart',
|
|
243
|
+
'onToolCallStart',
|
|
244
|
+
'onToolCallComplete',
|
|
245
|
+
'onStageComplete',
|
|
246
|
+
'onStageStart',
|
|
247
|
+
'onTaskRetry',
|
|
248
|
+
'onStageComplete',
|
|
249
|
+
'onPipelineComplete',
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('maps a rejected group run', () => {
|
|
254
|
+
const jsonlLines = [
|
|
255
|
+
{ event: 'pipeline_start', pipeline: 'test-pipe', run_id: 'ccdd3344', ts: '2026-02-22T14:00:00Z' },
|
|
256
|
+
{ event: 'group_start', group: 'impl-review', max_iterations: 3, ts: '2026-02-22T14:00:01Z' },
|
|
257
|
+
{ event: 'group_iteration', group: 'impl-review', iteration: 1, max_iterations: 3, ts: '2026-02-22T14:00:02Z' },
|
|
258
|
+
{ event: 'stage_start', stage: 'code-gen', stage_index: 0, total_stages: 2, ts: '2026-02-22T14:00:03Z' },
|
|
259
|
+
{ event: 'stage_complete', stage: 'code-gen', stage_index: 0, total_stages: 2, status: 'success', attempts: 1, duration_ms: 2000, ts: '2026-02-22T14:00:05Z' },
|
|
260
|
+
{ event: 'stage_start', stage: 'qa-review', stage_index: 1, total_stages: 2, ts: '2026-02-22T14:00:06Z' },
|
|
261
|
+
{ event: 'stage_complete', stage: 'qa-review', stage_index: 1, total_stages: 2, status: 'rejected', attempts: 1, duration_ms: 1500, rejection_reason: 'code incomplete', rejection_details: ['missing error handling'], ts: '2026-02-22T14:00:07Z' },
|
|
262
|
+
{ event: 'group_feedback', group: 'impl-review', iteration: 1, rejection_reason: 'code incomplete', rejection_details: ['missing error handling'], ts: '2026-02-22T14:00:07Z' },
|
|
263
|
+
{ event: 'group_complete', group: 'impl-review', iterations: 1, status: 'rejected', ts: '2026-02-22T14:00:07Z' },
|
|
264
|
+
{ event: 'pipeline_complete', pipeline_name: 'test-pipe', run_id: 'ccdd3344', status: 'rejected', duration_ms: 7000, total_tokens: 2000, total_tool_calls: 0, ts: '2026-02-22T14:00:07Z' },
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
const mapped = jsonlLines.map((line) => mapJsonlLineToEvent(line));
|
|
268
|
+
expect(mapped.every((m) => m !== null)).toBe(true);
|
|
269
|
+
|
|
270
|
+
const stageComplete = mapped.find((m) => m!.handler === 'onStageComplete' && m!.payload.status === 'rejected');
|
|
271
|
+
expect(stageComplete!.payload.rejection_reason).toBe('code incomplete');
|
|
272
|
+
expect(stageComplete!.payload.rejection_details).toEqual(['missing error handling']);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('skips corrupt JSONL lines gracefully', () => {
|
|
276
|
+
const validLine = { event: 'pipeline_start', pipeline: 'test', run_id: 'xxxx' };
|
|
277
|
+
const mapped = mapJsonlLineToEvent(validLine);
|
|
278
|
+
expect(mapped).not.toBeNull();
|
|
279
|
+
|
|
280
|
+
const unknownLine = { event: 'totally_unknown', data: 123 };
|
|
281
|
+
expect(mapJsonlLineToEvent(unknownLine)).toBeNull();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { validateTemplateDir } from '../../../src/commands/template/validate.js';
|
|
5
|
+
|
|
6
|
+
// Use /tmp — never a subdirectory of the Studio repo
|
|
7
|
+
const TMP = resolve('/tmp', '.studio-template-validate-test');
|
|
8
|
+
|
|
9
|
+
async function makeTemplate(overrides: {
|
|
10
|
+
metadata?: Record<string, unknown> | null;
|
|
11
|
+
pipelines?: Record<string, string>;
|
|
12
|
+
agents?: Record<string, string>;
|
|
13
|
+
contracts?: Record<string, string>;
|
|
14
|
+
} = {}): Promise<string> {
|
|
15
|
+
const dir = join(TMP, String(Date.now()));
|
|
16
|
+
await mkdir(join(dir, 'pipelines'), { recursive: true });
|
|
17
|
+
await mkdir(join(dir, 'agents'), { recursive: true });
|
|
18
|
+
await mkdir(join(dir, 'contracts'), { recursive: true });
|
|
19
|
+
await mkdir(join(dir, 'tools'), { recursive: true });
|
|
20
|
+
await mkdir(join(dir, 'inputs'), { recursive: true });
|
|
21
|
+
|
|
22
|
+
// metadata.json
|
|
23
|
+
if (overrides.metadata !== null) {
|
|
24
|
+
const meta = overrides.metadata ?? { name: 'test', version: '1.0.0', description: 'Test template' };
|
|
25
|
+
await writeFile(join(dir, 'metadata.json'), JSON.stringify(meta));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// pipelines — default: 2 valid pipelines
|
|
29
|
+
const pipelines = overrides.pipelines ?? {
|
|
30
|
+
'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
|
|
31
|
+
'pipe-two': 'name: pipe-two\nstages:\n - name: s2\n kind: analysis\n agent: analyst\n contract: output\n',
|
|
32
|
+
};
|
|
33
|
+
for (const [name, content] of Object.entries(pipelines)) {
|
|
34
|
+
await writeFile(join(dir, 'pipelines', `${name}.pipeline.yaml`), content);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// agents — default: 1 valid agent
|
|
38
|
+
const agents = overrides.agents ?? {
|
|
39
|
+
analyst: 'name: analyst\nprovider: anthropic\nmodel: claude-haiku-4-20250514\n',
|
|
40
|
+
};
|
|
41
|
+
for (const [name, content] of Object.entries(agents)) {
|
|
42
|
+
await writeFile(join(dir, 'agents', `${name}.agent.yaml`), content);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// contracts — default: 1 valid contract
|
|
46
|
+
const contracts = overrides.contracts ?? {
|
|
47
|
+
output: 'name: output\nversion: 1\nschema:\n required_fields:\n - summary\n',
|
|
48
|
+
};
|
|
49
|
+
for (const [name, content] of Object.entries(contracts)) {
|
|
50
|
+
await writeFile(join(dir, 'contracts', `${name}.contract.yaml`), content);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => { await mkdir(TMP, { recursive: true }); });
|
|
57
|
+
afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
|
|
58
|
+
|
|
59
|
+
describe('validateTemplateDir — Level 1: Structural', () => {
|
|
60
|
+
it('returns valid for a well-formed minimal template', async () => {
|
|
61
|
+
const dir = await makeTemplate();
|
|
62
|
+
const result = await validateTemplateDir(dir);
|
|
63
|
+
expect(result.valid).toBe(true);
|
|
64
|
+
expect(result.structuralErrors).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('errors when directory does not exist', async () => {
|
|
68
|
+
const result = await validateTemplateDir('/tmp/does-not-exist-xyz');
|
|
69
|
+
expect(result.valid).toBe(false);
|
|
70
|
+
expect(result.structuralErrors.some(e => e.includes('not found') || e.includes('does not exist'))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('errors when metadata.json is missing', async () => {
|
|
74
|
+
const dir = await makeTemplate({ metadata: null });
|
|
75
|
+
const result = await validateTemplateDir(dir);
|
|
76
|
+
expect(result.valid).toBe(false);
|
|
77
|
+
expect(result.structuralErrors.some(e => e.includes('metadata.json'))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('errors when metadata.json is missing required field', async () => {
|
|
81
|
+
const dir = await makeTemplate({ metadata: { name: 'test' } });
|
|
82
|
+
const result = await validateTemplateDir(dir);
|
|
83
|
+
expect(result.valid).toBe(false);
|
|
84
|
+
expect(result.structuralErrors.some(e => e.includes('version') || e.includes('description'))).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('accepts a template with a single pipeline', async () => {
|
|
88
|
+
const dir = await makeTemplate({
|
|
89
|
+
pipelines: { 'only-one': 'name: only-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n' },
|
|
90
|
+
});
|
|
91
|
+
const result = await validateTemplateDir(dir);
|
|
92
|
+
expect(result.valid).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('accepts a template with no pipelines or agents (e.g. blank template)', async () => {
|
|
96
|
+
const dir = await makeTemplate({ pipelines: {}, agents: {}, contracts: {} });
|
|
97
|
+
const result = await validateTemplateDir(dir);
|
|
98
|
+
expect(result.valid).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('validateTemplateDir — Level 2: Semantic', () => {
|
|
103
|
+
it('errors on invalid YAML in a pipeline file', async () => {
|
|
104
|
+
const dir = await makeTemplate({
|
|
105
|
+
pipelines: {
|
|
106
|
+
'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
|
|
107
|
+
'pipe-bad': ': invalid: yaml: [\n',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const result = await validateTemplateDir(dir);
|
|
111
|
+
expect(result.valid).toBe(false);
|
|
112
|
+
expect(result.semanticErrors.some(e => e.includes('pipe-bad'))).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('errors when pipeline stage references a contract that does not exist', async () => {
|
|
116
|
+
const dir = await makeTemplate({
|
|
117
|
+
pipelines: {
|
|
118
|
+
'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
|
|
119
|
+
'pipe-two': 'name: pipe-two\nstages:\n - name: s2\n kind: analysis\n agent: analyst\n contract: missing-contract\n',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const result = await validateTemplateDir(dir);
|
|
123
|
+
expect(result.valid).toBe(false);
|
|
124
|
+
expect(result.semanticErrors.some(e => e.includes('missing-contract'))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('errors when pipeline stage references an agent that does not exist', async () => {
|
|
128
|
+
const dir = await makeTemplate({
|
|
129
|
+
pipelines: {
|
|
130
|
+
'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
|
|
131
|
+
'pipe-two': 'name: pipe-two\nstages:\n - name: s2\n kind: analysis\n agent: missing-agent\n contract: output\n',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const result = await validateTemplateDir(dir);
|
|
135
|
+
expect(result.valid).toBe(false);
|
|
136
|
+
expect(result.semanticErrors.some(e => e.includes('missing-agent'))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('validates stages inside groups', async () => {
|
|
140
|
+
const dir = await makeTemplate({
|
|
141
|
+
pipelines: {
|
|
142
|
+
'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
|
|
143
|
+
'pipe-group': `name: pipe-group\nstages:\n - group: my-group\n max_iterations: 3\n stages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n - name: s2\n kind: qa\n agent: analyst\n contract: ghost-contract\n`,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const result = await validateTemplateDir(dir);
|
|
147
|
+
expect(result.valid).toBe(false);
|
|
148
|
+
expect(result.semanticErrors.some(e => e.includes('ghost-contract'))).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { listTemplates } from '../../src/commands/templates.js';
|
|
3
|
+
|
|
4
|
+
// Mock registry so tests only exercise local (bundled) templates
|
|
5
|
+
vi.mock('../../src/commands/registry/sync.js', () => ({
|
|
6
|
+
syncRegistry: vi.fn().mockResolvedValue(undefined),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('../../src/registry/cache.js', () => {
|
|
9
|
+
class RegistryCache {
|
|
10
|
+
read() { return Promise.resolve(null); }
|
|
11
|
+
write() { return Promise.resolve(undefined); }
|
|
12
|
+
isFresh() { return Promise.resolve(false); }
|
|
13
|
+
}
|
|
14
|
+
return { RegistryCache };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('listTemplates', () => {
|
|
18
|
+
it('returns blank as the only built-in template (others are in the registry)', async () => {
|
|
19
|
+
const templates = await listTemplates();
|
|
20
|
+
const names = templates.map((t) => t.name);
|
|
21
|
+
expect(names).toContain('blank');
|
|
22
|
+
expect(names).not.toContain('software');
|
|
23
|
+
expect(names).not.toContain('software-full');
|
|
24
|
+
expect(names).not.toContain('content');
|
|
25
|
+
expect(names).not.toContain('document-analysis');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('each template has name, version, description', async () => {
|
|
29
|
+
const templates = await listTemplates();
|
|
30
|
+
for (const t of templates) {
|
|
31
|
+
expect(t.name).toBeTruthy();
|
|
32
|
+
expect(t.version).toBeTruthy();
|
|
33
|
+
expect(t.description).toBeTruthy();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('templates are sorted alphabetically', async () => {
|
|
38
|
+
const templates = await listTemplates();
|
|
39
|
+
const names = templates.map((t) => t.name);
|
|
40
|
+
expect(names).toEqual([...names].sort());
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, rm, writeFile, access } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { listTools, getToolsDir, listAvailableTools, toolsAddDirect } from '../../src/commands/tools.js';
|
|
5
|
+
|
|
6
|
+
const TMP = resolve(import.meta.dirname, '.tmp-tools-test');
|
|
7
|
+
const STUDIO_DIR = resolve(TMP, '.studio');
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => { await mkdir(STUDIO_DIR, { recursive: true }); });
|
|
10
|
+
afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
|
|
11
|
+
|
|
12
|
+
describe('listTools', () => {
|
|
13
|
+
it('returns empty array when no tools dir', async () => {
|
|
14
|
+
const result = await listTools(resolve(STUDIO_DIR, 'tools'));
|
|
15
|
+
expect(result).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('finds .tool.yaml files', async () => {
|
|
19
|
+
const toolsDir = resolve(STUDIO_DIR, 'tools');
|
|
20
|
+
await mkdir(toolsDir, { recursive: true });
|
|
21
|
+
await writeFile(resolve(toolsDir, 'git.tool.yaml'), 'name: git\n');
|
|
22
|
+
await writeFile(resolve(toolsDir, 'other.txt'), 'ignored\n');
|
|
23
|
+
const result = await listTools(toolsDir);
|
|
24
|
+
expect(result).toEqual(['git']);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('getToolsDir', () => {
|
|
29
|
+
it('resolves tools dir from studioDir', () => {
|
|
30
|
+
const dir = getToolsDir(STUDIO_DIR);
|
|
31
|
+
expect(dir).toBe(resolve(STUDIO_DIR, 'tools'));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const TOOLS_TMP = resolve('/tmp', '.studio-tools-add-test-' + Math.floor(Date.now() / 1000));
|
|
36
|
+
const TOOLS_STUDIO_DIR = resolve(TOOLS_TMP, '.studio');
|
|
37
|
+
|
|
38
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
39
|
+
try { await access(p); return true; } catch { return false; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('toolsAddDirect', () => {
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
await mkdir(TOOLS_STUDIO_DIR, { recursive: true });
|
|
45
|
+
});
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await rm(TOOLS_TMP, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('installs a single valid tool', async () => {
|
|
51
|
+
const result = await toolsAddDirect(TOOLS_STUDIO_DIR, ['git']);
|
|
52
|
+
expect(result.installed).toEqual(['git']);
|
|
53
|
+
expect(result.skipped).toEqual([]);
|
|
54
|
+
const toolPath = resolve(TOOLS_STUDIO_DIR, 'tools', 'git.tool.yaml');
|
|
55
|
+
expect(await fileExists(toolPath)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('installs multiple tools in one call', async () => {
|
|
59
|
+
const result = await toolsAddDirect(TOOLS_STUDIO_DIR, ['git', 'shell']);
|
|
60
|
+
expect(result.installed).toContain('git');
|
|
61
|
+
expect(result.installed).toContain('shell');
|
|
62
|
+
expect(result.skipped).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('skips already-installed tool and returns it in skipped list', async () => {
|
|
66
|
+
await toolsAddDirect(TOOLS_STUDIO_DIR, ['git']);
|
|
67
|
+
const result = await toolsAddDirect(TOOLS_STUDIO_DIR, ['git', 'shell']);
|
|
68
|
+
expect(result.installed).toEqual(['shell']);
|
|
69
|
+
expect(result.skipped).toEqual(['git']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('throws on unknown tool name', async () => {
|
|
73
|
+
await expect(toolsAddDirect(TOOLS_STUDIO_DIR, ['nonexistent'])).rejects.toThrow("Unknown tool 'nonexistent'");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('creates tools dir if it does not exist', async () => {
|
|
77
|
+
const result = await toolsAddDirect(TOOLS_STUDIO_DIR, ['search']);
|
|
78
|
+
expect(result.installed).toEqual(['search']);
|
|
79
|
+
expect(await fileExists(resolve(TOOLS_STUDIO_DIR, 'tools', 'search.tool.yaml'))).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('listAvailableTools', () => {
|
|
84
|
+
it('returns all available tool templates', async () => {
|
|
85
|
+
const tools = await listAvailableTools();
|
|
86
|
+
const names = tools.map((t) => t.name);
|
|
87
|
+
expect(names).toContain('git');
|
|
88
|
+
expect(names).toContain('repo-manager');
|
|
89
|
+
expect(names).toContain('shell');
|
|
90
|
+
expect(names).toContain('search');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns description for each tool', async () => {
|
|
94
|
+
const tools = await listAvailableTools();
|
|
95
|
+
for (const t of tools) {
|
|
96
|
+
expect(typeof t.description).toBe('string');
|
|
97
|
+
expect(t.description.length).toBeGreaterThan(0);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns tools sorted by name', async () => {
|
|
102
|
+
const tools = await listAvailableTools();
|
|
103
|
+
const names = tools.map((t) => t.name);
|
|
104
|
+
expect(names).toEqual([...names].sort());
|
|
105
|
+
});
|
|
106
|
+
});
|