edsger 0.46.0 → 0.48.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/.claude/settings.local.json +3 -23
- package/dist/api/__tests__/app-store.test.d.ts +7 -0
- package/dist/api/__tests__/app-store.test.js +60 -0
- package/dist/api/__tests__/intelligence.test.d.ts +11 -0
- package/dist/api/__tests__/intelligence.test.js +315 -0
- package/dist/api/features/__tests__/feature-utils.test.d.ts +4 -0
- package/dist/api/features/__tests__/feature-utils.test.js +370 -0
- package/dist/api/features/__tests__/status-updater.test.d.ts +4 -0
- package/dist/api/features/__tests__/status-updater.test.js +88 -0
- package/dist/commands/build/__tests__/build.test.d.ts +5 -0
- package/dist/commands/build/__tests__/build.test.js +206 -0
- package/dist/commands/build/__tests__/detect-project.test.d.ts +6 -0
- package/dist/commands/build/__tests__/detect-project.test.js +160 -0
- package/dist/commands/build/__tests__/run-build.test.d.ts +6 -0
- package/dist/commands/build/__tests__/run-build.test.js +433 -0
- package/dist/commands/intelligence/__tests__/command.test.d.ts +4 -0
- package/dist/commands/intelligence/__tests__/command.test.js +48 -0
- package/dist/commands/run-sheet/index.js +6 -0
- package/dist/commands/workflow/core/__tests__/feature-filter.test.d.ts +5 -0
- package/dist/commands/workflow/core/__tests__/feature-filter.test.js +316 -0
- package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.d.ts +4 -0
- package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.js +397 -0
- package/dist/commands/workflow/core/__tests__/state-manager.test.d.ts +4 -0
- package/dist/commands/workflow/core/__tests__/state-manager.test.js +384 -0
- package/dist/config/__tests__/config.test.d.ts +4 -0
- package/dist/config/__tests__/config.test.js +286 -0
- package/dist/config/__tests__/feature-status.test.d.ts +4 -0
- package/dist/config/__tests__/feature-status.test.js +111 -0
- package/dist/errors/__tests__/index.test.d.ts +4 -0
- package/dist/errors/__tests__/index.test.js +349 -0
- package/dist/index.js +0 -0
- package/dist/phases/app-store-generation/__tests__/agent.test.d.ts +5 -0
- package/dist/phases/app-store-generation/__tests__/agent.test.js +142 -0
- package/dist/phases/app-store-generation/__tests__/context.test.d.ts +4 -0
- package/dist/phases/app-store-generation/__tests__/context.test.js +284 -0
- package/dist/phases/app-store-generation/__tests__/prompts.test.d.ts +4 -0
- package/dist/phases/app-store-generation/__tests__/prompts.test.js +122 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.d.ts +5 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +826 -0
- package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
- package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
- package/dist/phases/intelligence-analysis/__tests__/context.test.d.ts +4 -0
- package/dist/phases/intelligence-analysis/__tests__/context.test.js +192 -0
- package/dist/phases/intelligence-analysis/__tests__/matching.test.d.ts +13 -0
- package/dist/phases/intelligence-analysis/__tests__/matching.test.js +154 -0
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.d.ts +5 -0
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +378 -0
- package/dist/phases/intelligence-analysis/__tests__/prompts.test.d.ts +4 -0
- package/dist/phases/intelligence-analysis/__tests__/prompts.test.js +33 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.d.ts +1 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.js +303 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +157 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
- package/dist/phases/pr-resolve/__tests__/types.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/types.test.js +43 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
- package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.js +110 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
- package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +1 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +331 -0
- package/dist/phases/release-sync/github.d.ts +12 -0
- package/dist/phases/release-sync/github.js +39 -0
- package/dist/phases/release-sync/snapshot.js +0 -1
- package/dist/phases/run-sheet/index.d.ts +15 -0
- package/dist/phases/run-sheet/index.js +154 -22
- package/dist/phases/run-sheet/render.d.ts +23 -5
- package/dist/phases/run-sheet/render.js +193 -31
- package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
- package/dist/phases/smoke-test/__tests__/agent.test.js +84 -0
- package/dist/phases/smoke-test/__tests__/github.test.d.ts +9 -0
- package/dist/phases/smoke-test/__tests__/github.test.js +120 -0
- package/dist/phases/smoke-test/__tests__/snapshot.test.d.ts +8 -0
- package/dist/phases/smoke-test/__tests__/snapshot.test.js +93 -0
- package/dist/phases/smoke-test/github.d.ts +54 -0
- package/dist/phases/smoke-test/github.js +101 -0
- package/dist/phases/smoke-test/snapshot.d.ts +27 -0
- package/dist/phases/smoke-test/snapshot.js +157 -0
- package/dist/services/coaching/__tests__/coaching-agent.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/coaching-agent.test.js +74 -0
- package/dist/services/coaching/__tests__/coaching-loop.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/coaching-loop.test.js +59 -0
- package/dist/services/coaching/__tests__/self-rating.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/self-rating.test.js +188 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +4 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +133 -0
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +4 -0
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +336 -0
- package/dist/services/lifecycle-agent/index.d.ts +24 -0
- package/dist/services/lifecycle-agent/index.js +25 -0
- package/dist/services/lifecycle-agent/phase-criteria.d.ts +57 -0
- package/dist/services/lifecycle-agent/phase-criteria.js +335 -0
- package/dist/services/lifecycle-agent/transition-rules.d.ts +60 -0
- package/dist/services/lifecycle-agent/transition-rules.js +184 -0
- package/dist/services/lifecycle-agent/types.d.ts +190 -0
- package/dist/services/lifecycle-agent/types.js +12 -0
- package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.js +122 -0
- package/dist/services/phase-hooks/__tests__/hook-executor.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/hook-executor.test.js +321 -0
- package/dist/services/phase-hooks/__tests__/hook-runner.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/hook-runner.test.js +261 -0
- package/dist/services/phase-hooks/__tests__/plugin-loader.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/plugin-loader.test.js +158 -0
- package/dist/services/video/__tests__/video-pipeline.test.d.ts +6 -0
- package/dist/services/video/__tests__/video-pipeline.test.js +249 -0
- package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
- package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
- package/dist/workspace/workspace-manager.js +17 -4
- package/package.json +1 -1
- package/.env.local +0 -12
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { runHooksForPhase } from '../hook-runner.js';
|
|
4
|
+
// ---- Helpers ----
|
|
5
|
+
function makeBinding(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
id: 'hook-1',
|
|
8
|
+
product_id: 'prod-1',
|
|
9
|
+
phase: 'technical-design',
|
|
10
|
+
hook_point: 'after',
|
|
11
|
+
plugin_name: 'payload-cms',
|
|
12
|
+
skill_name: 'validate-schema',
|
|
13
|
+
on_failure: 'block',
|
|
14
|
+
config: {},
|
|
15
|
+
sort_order: 0,
|
|
16
|
+
enabled: true,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function makeResult(binding, overrides = {}) {
|
|
21
|
+
return {
|
|
22
|
+
hookId: binding.id,
|
|
23
|
+
binding,
|
|
24
|
+
status: 'success',
|
|
25
|
+
message: 'OK',
|
|
26
|
+
duration_ms: 100,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function makeContext(overrides = {}) {
|
|
31
|
+
return {
|
|
32
|
+
featureId: 'feat-1',
|
|
33
|
+
phase: 'technical-design',
|
|
34
|
+
hookPoint: 'after',
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function makeDeps(overrides = {}) {
|
|
39
|
+
return {
|
|
40
|
+
executeHook: (binding) => Promise.resolve(makeResult(binding)),
|
|
41
|
+
getCachedBindings: () => null,
|
|
42
|
+
getBindingsForPhase: () => [],
|
|
43
|
+
logHookEvent: () => Promise.resolve(),
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// ---- Tests ----
|
|
48
|
+
void describe('runHooksForPhase', () => {
|
|
49
|
+
void it('returns empty results when no cached bindings', async () => {
|
|
50
|
+
const deps = makeDeps({ getCachedBindings: () => null });
|
|
51
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
52
|
+
assert.deepStrictEqual(result, { results: [], blocked: false });
|
|
53
|
+
});
|
|
54
|
+
void it('returns empty results when cache has empty bindings', async () => {
|
|
55
|
+
const deps = makeDeps({
|
|
56
|
+
getCachedBindings: () => ({
|
|
57
|
+
productId: 'prod-1',
|
|
58
|
+
bindings: [],
|
|
59
|
+
fetchedAt: Date.now(),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
63
|
+
assert.deepStrictEqual(result, { results: [], blocked: false });
|
|
64
|
+
});
|
|
65
|
+
void it('returns empty results when no bindings match the phase', async () => {
|
|
66
|
+
const cached = {
|
|
67
|
+
productId: 'prod-1',
|
|
68
|
+
bindings: [makeBinding({ phase: 'code-review' })],
|
|
69
|
+
fetchedAt: Date.now(),
|
|
70
|
+
};
|
|
71
|
+
const deps = makeDeps({
|
|
72
|
+
getCachedBindings: () => cached,
|
|
73
|
+
getBindingsForPhase: () => [], // no match
|
|
74
|
+
});
|
|
75
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
76
|
+
assert.deepStrictEqual(result, { results: [], blocked: false });
|
|
77
|
+
});
|
|
78
|
+
void it('executes matching hooks and returns results', async () => {
|
|
79
|
+
const binding = makeBinding();
|
|
80
|
+
const cached = {
|
|
81
|
+
productId: 'prod-1',
|
|
82
|
+
bindings: [binding],
|
|
83
|
+
fetchedAt: Date.now(),
|
|
84
|
+
};
|
|
85
|
+
const executeCalls = [];
|
|
86
|
+
const deps = makeDeps({
|
|
87
|
+
getCachedBindings: () => cached,
|
|
88
|
+
getBindingsForPhase: () => [binding],
|
|
89
|
+
executeHook: (b) => {
|
|
90
|
+
executeCalls.push(b);
|
|
91
|
+
return Promise.resolve(makeResult(b));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
95
|
+
assert.strictEqual(result.results.length, 1);
|
|
96
|
+
assert.strictEqual(result.blocked, false);
|
|
97
|
+
assert.strictEqual(executeCalls.length, 1);
|
|
98
|
+
assert.strictEqual(executeCalls[0].id, 'hook-1');
|
|
99
|
+
});
|
|
100
|
+
void it('executes hooks in order (sequential)', async () => {
|
|
101
|
+
const b1 = makeBinding({ id: 'first', sort_order: 0 });
|
|
102
|
+
const b2 = makeBinding({ id: 'second', sort_order: 1 });
|
|
103
|
+
const cached = {
|
|
104
|
+
productId: 'prod-1',
|
|
105
|
+
bindings: [b1, b2],
|
|
106
|
+
fetchedAt: Date.now(),
|
|
107
|
+
};
|
|
108
|
+
const order = [];
|
|
109
|
+
const deps = makeDeps({
|
|
110
|
+
getCachedBindings: () => cached,
|
|
111
|
+
getBindingsForPhase: () => [b1, b2],
|
|
112
|
+
executeHook: (b) => {
|
|
113
|
+
order.push(b.id);
|
|
114
|
+
return Promise.resolve(makeResult(b));
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
await runHooksForPhase(makeContext(), deps);
|
|
118
|
+
assert.deepStrictEqual(order, ['first', 'second']);
|
|
119
|
+
});
|
|
120
|
+
void it('blocks on error with on_failure=block', async () => {
|
|
121
|
+
const b1 = makeBinding({ id: 'blocker', on_failure: 'block' });
|
|
122
|
+
const b2 = makeBinding({
|
|
123
|
+
id: 'after-blocker',
|
|
124
|
+
on_failure: 'block',
|
|
125
|
+
sort_order: 1,
|
|
126
|
+
});
|
|
127
|
+
const cached = {
|
|
128
|
+
productId: 'prod-1',
|
|
129
|
+
bindings: [b1, b2],
|
|
130
|
+
fetchedAt: Date.now(),
|
|
131
|
+
};
|
|
132
|
+
const executedIds = [];
|
|
133
|
+
const deps = makeDeps({
|
|
134
|
+
getCachedBindings: () => cached,
|
|
135
|
+
getBindingsForPhase: () => [b1, b2],
|
|
136
|
+
executeHook: (b) => {
|
|
137
|
+
executedIds.push(b.id);
|
|
138
|
+
if (b.id === 'blocker') {
|
|
139
|
+
return Promise.resolve(makeResult(b, {
|
|
140
|
+
status: 'error',
|
|
141
|
+
message: 'Validation failed',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
return Promise.resolve(makeResult(b));
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
148
|
+
assert.strictEqual(result.blocked, true);
|
|
149
|
+
assert.strictEqual(result.results.length, 1); // only the blocker ran
|
|
150
|
+
assert.strictEqual(result.results[0].status, 'error');
|
|
151
|
+
assert.deepStrictEqual(executedIds, ['blocker']); // second hook never ran
|
|
152
|
+
});
|
|
153
|
+
void it('continues on error with on_failure=warn', async () => {
|
|
154
|
+
const b1 = makeBinding({ id: 'warner', on_failure: 'warn' });
|
|
155
|
+
const b2 = makeBinding({
|
|
156
|
+
id: 'after-warner',
|
|
157
|
+
on_failure: 'block',
|
|
158
|
+
sort_order: 1,
|
|
159
|
+
});
|
|
160
|
+
const cached = {
|
|
161
|
+
productId: 'prod-1',
|
|
162
|
+
bindings: [b1, b2],
|
|
163
|
+
fetchedAt: Date.now(),
|
|
164
|
+
};
|
|
165
|
+
const deps = makeDeps({
|
|
166
|
+
getCachedBindings: () => cached,
|
|
167
|
+
getBindingsForPhase: () => [b1, b2],
|
|
168
|
+
executeHook: (b) => {
|
|
169
|
+
if (b.id === 'warner') {
|
|
170
|
+
return Promise.resolve(makeResult(b, {
|
|
171
|
+
status: 'error',
|
|
172
|
+
message: 'Non-critical issue',
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
return Promise.resolve(makeResult(b));
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
179
|
+
assert.strictEqual(result.blocked, false);
|
|
180
|
+
assert.strictEqual(result.results.length, 2); // both ran
|
|
181
|
+
assert.strictEqual(result.results[0].status, 'error');
|
|
182
|
+
assert.strictEqual(result.results[1].status, 'success');
|
|
183
|
+
});
|
|
184
|
+
void it('continues silently on error with on_failure=skip', async () => {
|
|
185
|
+
const b1 = makeBinding({ id: 'skipper', on_failure: 'skip' });
|
|
186
|
+
const b2 = makeBinding({ id: 'after-skipper', sort_order: 1 });
|
|
187
|
+
const cached = {
|
|
188
|
+
productId: 'prod-1',
|
|
189
|
+
bindings: [b1, b2],
|
|
190
|
+
fetchedAt: Date.now(),
|
|
191
|
+
};
|
|
192
|
+
const deps = makeDeps({
|
|
193
|
+
getCachedBindings: () => cached,
|
|
194
|
+
getBindingsForPhase: () => [b1, b2],
|
|
195
|
+
executeHook: (b) => {
|
|
196
|
+
if (b.id === 'skipper') {
|
|
197
|
+
return Promise.resolve(makeResult(b, { status: 'error', message: 'Ignored' }));
|
|
198
|
+
}
|
|
199
|
+
return Promise.resolve(makeResult(b));
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
203
|
+
assert.strictEqual(result.blocked, false);
|
|
204
|
+
assert.strictEqual(result.results.length, 2);
|
|
205
|
+
});
|
|
206
|
+
void it('logs events for each hook execution', async () => {
|
|
207
|
+
const binding = makeBinding();
|
|
208
|
+
const cached = {
|
|
209
|
+
productId: 'prod-1',
|
|
210
|
+
bindings: [binding],
|
|
211
|
+
fetchedAt: Date.now(),
|
|
212
|
+
};
|
|
213
|
+
const logCalls = [];
|
|
214
|
+
const deps = makeDeps({
|
|
215
|
+
getCachedBindings: () => cached,
|
|
216
|
+
getBindingsForPhase: () => [binding],
|
|
217
|
+
logHookEvent: ({ result: r }) => {
|
|
218
|
+
logCalls.push(r.hookId);
|
|
219
|
+
return Promise.resolve();
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
await runHooksForPhase(makeContext(), deps);
|
|
223
|
+
assert.deepStrictEqual(logCalls, ['hook-1']);
|
|
224
|
+
});
|
|
225
|
+
void it('does not block when logHookEvent throws', async () => {
|
|
226
|
+
const binding = makeBinding();
|
|
227
|
+
const cached = {
|
|
228
|
+
productId: 'prod-1',
|
|
229
|
+
bindings: [binding],
|
|
230
|
+
fetchedAt: Date.now(),
|
|
231
|
+
};
|
|
232
|
+
const deps = makeDeps({
|
|
233
|
+
getCachedBindings: () => cached,
|
|
234
|
+
getBindingsForPhase: () => [binding],
|
|
235
|
+
logHookEvent: () => {
|
|
236
|
+
return Promise.reject(new Error('Logging failed'));
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
240
|
+
// Should still succeed — logging failure is swallowed
|
|
241
|
+
assert.strictEqual(result.blocked, false);
|
|
242
|
+
assert.strictEqual(result.results.length, 1);
|
|
243
|
+
});
|
|
244
|
+
void it('handles skipped hooks (file not found) without blocking', async () => {
|
|
245
|
+
const binding = makeBinding({ on_failure: 'block' });
|
|
246
|
+
const cached = {
|
|
247
|
+
productId: 'prod-1',
|
|
248
|
+
bindings: [binding],
|
|
249
|
+
fetchedAt: Date.now(),
|
|
250
|
+
};
|
|
251
|
+
const deps = makeDeps({
|
|
252
|
+
getCachedBindings: () => cached,
|
|
253
|
+
getBindingsForPhase: () => [binding],
|
|
254
|
+
executeHook: (b) => Promise.resolve(makeResult(b, { status: 'skipped', message: 'Not found' })),
|
|
255
|
+
});
|
|
256
|
+
const result = await runHooksForPhase(makeContext(), deps);
|
|
257
|
+
// 'skipped' is not an error, so on_failure policy should not apply
|
|
258
|
+
assert.strictEqual(result.blocked, false);
|
|
259
|
+
assert.strictEqual(result.results[0].status, 'skipped');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
6
|
+
import { getPluginCachePath, loadSkillFile, resolveSkillFilePaths, } from '../plugin-loader.js';
|
|
7
|
+
// ---- resolveSkillFilePaths ----
|
|
8
|
+
void describe('resolveSkillFilePaths', () => {
|
|
9
|
+
void it('returns skills/ path first, commands/ path second', () => {
|
|
10
|
+
const paths = resolveSkillFilePaths('payload-cms', 'validate-schema');
|
|
11
|
+
const cache = getPluginCachePath();
|
|
12
|
+
assert.strictEqual(paths.length, 2);
|
|
13
|
+
assert.strictEqual(paths[0], path.join(cache, 'payload-cms', 'skills', 'validate-schema', 'SKILL.md'));
|
|
14
|
+
assert.strictEqual(paths[1], path.join(cache, 'payload-cms', 'commands', 'validate-schema.md'));
|
|
15
|
+
});
|
|
16
|
+
void it('uses custom cacheDir when provided', () => {
|
|
17
|
+
const paths = resolveSkillFilePaths('my-plugin', 'my-skill', '/tmp/test-cache');
|
|
18
|
+
assert.ok(paths[0].startsWith('/tmp/test-cache/my-plugin'));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
// ---- getPluginCachePath ----
|
|
22
|
+
void describe('getPluginCachePath', () => {
|
|
23
|
+
void it('returns a path under home directory', () => {
|
|
24
|
+
const cachePath = getPluginCachePath();
|
|
25
|
+
assert.ok(cachePath.startsWith(os.homedir()));
|
|
26
|
+
assert.ok(cachePath.endsWith(path.join('.claude', 'plugins', 'cache')));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
// ---- loadSkillFile ----
|
|
30
|
+
void describe('loadSkillFile', () => {
|
|
31
|
+
let tmpDir;
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
tmpDir = await import('node:fs/promises').then((fs) => fs.mkdtemp(path.join(os.tmpdir(), 'edsger-hook-test-')));
|
|
34
|
+
});
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
// -- Flat structure: cache/{pluginName}/skills/{skill}/SKILL.md --
|
|
39
|
+
void it('loads from flat structure', async () => {
|
|
40
|
+
const skillDir = path.join(tmpDir, 'my-plugin', 'skills', 'check');
|
|
41
|
+
await mkdir(skillDir, { recursive: true });
|
|
42
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), '---\nmodel: sonnet\nmaxTurns: 10\n---\nCheck the code.');
|
|
43
|
+
const result = await loadSkillFile('my-plugin', 'check', false, tmpDir);
|
|
44
|
+
assert.ok(result);
|
|
45
|
+
assert.strictEqual(result.frontmatter.model, 'sonnet');
|
|
46
|
+
assert.strictEqual(result.frontmatter.maxTurns, 10);
|
|
47
|
+
assert.strictEqual(result.body, 'Check the code.');
|
|
48
|
+
});
|
|
49
|
+
// -- Nested structure: cache/{marketplace}/{plugin}/{version}/skills/... --
|
|
50
|
+
void it('loads from nested marketplace cache structure', async () => {
|
|
51
|
+
// Simulate: cache/edsger-local-ing/ing/1.0.0/skills/review-local/SKILL.md
|
|
52
|
+
const skillDir = path.join(tmpDir, 'edsger-local-ing', 'ing', '1.0.0', 'skills', 'review-local');
|
|
53
|
+
const manifestDir = path.join(tmpDir, 'edsger-local-ing', 'ing', '1.0.0', '.claude-plugin');
|
|
54
|
+
await mkdir(skillDir, { recursive: true });
|
|
55
|
+
await mkdir(manifestDir, { recursive: true });
|
|
56
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), '---\ndescription: Review local changes\n---\nReview the diff.');
|
|
57
|
+
await writeFile(path.join(manifestDir, 'plugin.json'), JSON.stringify({ name: 'ing', version: '1.0.0' }));
|
|
58
|
+
// loadSkillFile('ing', 'review-local') should find it via manifest name match
|
|
59
|
+
const result = await loadSkillFile('ing', 'review-local', false, tmpDir);
|
|
60
|
+
assert.ok(result, 'Should find skill in nested structure');
|
|
61
|
+
assert.strictEqual(result.frontmatter.description, 'Review local changes');
|
|
62
|
+
assert.strictEqual(result.body, 'Review the diff.');
|
|
63
|
+
});
|
|
64
|
+
// -- Fallback to commands/ --
|
|
65
|
+
void it('falls back to commands/ when skills/ does not exist', async () => {
|
|
66
|
+
const commandsDir = path.join(tmpDir, 'legacy-plugin', 'commands');
|
|
67
|
+
await mkdir(commandsDir, { recursive: true });
|
|
68
|
+
await writeFile(path.join(commandsDir, 'deploy.md'), '---\ndescription: Deploy to staging\n---\nDeploy the branch.');
|
|
69
|
+
const result = await loadSkillFile('legacy-plugin', 'deploy', false, tmpDir);
|
|
70
|
+
assert.ok(result);
|
|
71
|
+
assert.strictEqual(result.frontmatter.description, 'Deploy to staging');
|
|
72
|
+
});
|
|
73
|
+
// -- Prefers skills/ over commands/ --
|
|
74
|
+
void it('prefers skills/ over commands/ when both exist', async () => {
|
|
75
|
+
const skillDir = path.join(tmpDir, 'dual-plugin', 'skills', 'check');
|
|
76
|
+
const cmdDir = path.join(tmpDir, 'dual-plugin', 'commands');
|
|
77
|
+
await mkdir(skillDir, { recursive: true });
|
|
78
|
+
await mkdir(cmdDir, { recursive: true });
|
|
79
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), '---\nsource: skill\n---\nFrom skills/');
|
|
80
|
+
await writeFile(path.join(cmdDir, 'check.md'), '---\nsource: command\n---\nFrom commands/');
|
|
81
|
+
const result = await loadSkillFile('dual-plugin', 'check', false, tmpDir);
|
|
82
|
+
assert.ok(result);
|
|
83
|
+
assert.strictEqual(result.frontmatter.source, 'skill');
|
|
84
|
+
});
|
|
85
|
+
// -- Edge cases --
|
|
86
|
+
void it('handles SKILL.md with no frontmatter', async () => {
|
|
87
|
+
const skillDir = path.join(tmpDir, 'bare', 'skills', 'simple');
|
|
88
|
+
await mkdir(skillDir, { recursive: true });
|
|
89
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), 'Plain instructions.');
|
|
90
|
+
const result = await loadSkillFile('bare', 'simple', false, tmpDir);
|
|
91
|
+
assert.ok(result);
|
|
92
|
+
assert.deepStrictEqual(result.frontmatter, {});
|
|
93
|
+
assert.strictEqual(result.body, 'Plain instructions.');
|
|
94
|
+
});
|
|
95
|
+
void it('handles SKILL.md with empty frontmatter', async () => {
|
|
96
|
+
const skillDir = path.join(tmpDir, 'empty-fm', 'skills', 'e');
|
|
97
|
+
await mkdir(skillDir, { recursive: true });
|
|
98
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), '---\n---\nBody only.');
|
|
99
|
+
const result = await loadSkillFile('empty-fm', 'e', false, tmpDir);
|
|
100
|
+
assert.ok(result);
|
|
101
|
+
assert.deepStrictEqual(result.frontmatter, {});
|
|
102
|
+
assert.strictEqual(result.body, 'Body only.');
|
|
103
|
+
});
|
|
104
|
+
void it('returns null when plugin does not exist', async () => {
|
|
105
|
+
const result = await loadSkillFile('nonexistent', 'nope', false, tmpDir);
|
|
106
|
+
assert.strictEqual(result, null);
|
|
107
|
+
});
|
|
108
|
+
void it('trims body whitespace', async () => {
|
|
109
|
+
const skillDir = path.join(tmpDir, 'trim', 'skills', 'padded');
|
|
110
|
+
await mkdir(skillDir, { recursive: true });
|
|
111
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), '---\n---\n\n Padded. \n\n');
|
|
112
|
+
const result = await loadSkillFile('trim', 'padded', false, tmpDir);
|
|
113
|
+
assert.ok(result);
|
|
114
|
+
assert.strictEqual(result.body, 'Padded.');
|
|
115
|
+
});
|
|
116
|
+
// -- Nested structure without manifest (match by directory name) --
|
|
117
|
+
void it('loads from nested structure when plugin dir name matches', async () => {
|
|
118
|
+
// cache/some-marketplace/my-tool/2.0.0/skills/scan/SKILL.md
|
|
119
|
+
const skillDir = path.join(tmpDir, 'some-marketplace', 'my-tool', '2.0.0', 'skills', 'scan');
|
|
120
|
+
await mkdir(skillDir, { recursive: true });
|
|
121
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), '---\n---\nScan it.');
|
|
122
|
+
// Should find via subdirectory name matching pluginName
|
|
123
|
+
const result = await loadSkillFile('my-tool', 'scan', false, tmpDir);
|
|
124
|
+
assert.ok(result, 'Should find skill by directory name match');
|
|
125
|
+
assert.strictEqual(result.body, 'Scan it.');
|
|
126
|
+
});
|
|
127
|
+
// -- Nested structure: match by manifest name (dir name differs) --
|
|
128
|
+
void it('loads via manifest name when cache dir name differs from plugin name', async () => {
|
|
129
|
+
// cache/random-key/1.0.0/skills/deploy/SKILL.md
|
|
130
|
+
// cache/random-key/1.0.0/.claude-plugin/plugin.json → { "name": "deployer" }
|
|
131
|
+
// loadSkillFile('deployer', 'deploy') should find it by scanning manifest
|
|
132
|
+
const pluginRoot = path.join(tmpDir, 'random-key', '1.0.0');
|
|
133
|
+
const skillDir = path.join(pluginRoot, 'skills', 'deploy');
|
|
134
|
+
const manifestDir = path.join(pluginRoot, '.claude-plugin');
|
|
135
|
+
await mkdir(skillDir, { recursive: true });
|
|
136
|
+
await mkdir(manifestDir, { recursive: true });
|
|
137
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), '---\ndescription: Deploy app\n---\nRun deploy.');
|
|
138
|
+
await writeFile(path.join(manifestDir, 'plugin.json'), JSON.stringify({ name: 'deployer', version: '1.0.0' }));
|
|
139
|
+
// 'deployer' doesn't appear in any directory name — only in manifest
|
|
140
|
+
const result = await loadSkillFile('deployer', 'deploy', false, tmpDir);
|
|
141
|
+
assert.ok(result, 'Should find skill via manifest name scan');
|
|
142
|
+
assert.strictEqual(result.frontmatter.description, 'Deploy app');
|
|
143
|
+
assert.strictEqual(result.body, 'Run deploy.');
|
|
144
|
+
});
|
|
145
|
+
void it('does not match manifest name of a different plugin', async () => {
|
|
146
|
+
// cache/some-key/1.0.0/.claude-plugin/plugin.json → { "name": "other-plugin" }
|
|
147
|
+
const pluginRoot = path.join(tmpDir, 'some-key', '1.0.0');
|
|
148
|
+
const skillDir = path.join(pluginRoot, 'skills', 'action');
|
|
149
|
+
const manifestDir = path.join(pluginRoot, '.claude-plugin');
|
|
150
|
+
await mkdir(skillDir, { recursive: true });
|
|
151
|
+
await mkdir(manifestDir, { recursive: true });
|
|
152
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), '---\n---\nDo something.');
|
|
153
|
+
await writeFile(path.join(manifestDir, 'plugin.json'), JSON.stringify({ name: 'other-plugin' }));
|
|
154
|
+
// Looking for 'wrong-name' — should NOT match 'other-plugin'
|
|
155
|
+
const result = await loadSkillFile('wrong-name', 'action', false, tmpDir);
|
|
156
|
+
assert.strictEqual(result, null);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the video generation pipeline.
|
|
3
|
+
* Tests pure logic functions that don't require external dependencies
|
|
4
|
+
* (Playwright, ffmpeg, TTS APIs).
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
// ============================================================
|
|
9
|
+
// HTML Template Validation (inline re-implementation for testing)
|
|
10
|
+
// ============================================================
|
|
11
|
+
function escapeHtml(str) {
|
|
12
|
+
return str
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, ''');
|
|
18
|
+
}
|
|
19
|
+
function validateHtmlTemplate(html) {
|
|
20
|
+
if (!html || html.trim().length < 50) {
|
|
21
|
+
return 'fallback';
|
|
22
|
+
}
|
|
23
|
+
const lower = html.toLowerCase();
|
|
24
|
+
if (!lower.includes('<html') &&
|
|
25
|
+
!lower.includes('<body') &&
|
|
26
|
+
!lower.includes('<div')) {
|
|
27
|
+
return 'fallback';
|
|
28
|
+
}
|
|
29
|
+
if (!lower.includes('<html')) {
|
|
30
|
+
return 'wrapped';
|
|
31
|
+
}
|
|
32
|
+
return 'valid';
|
|
33
|
+
}
|
|
34
|
+
void describe('HTML Template Validation', () => {
|
|
35
|
+
void it('should reject empty templates', () => {
|
|
36
|
+
assert.strictEqual(validateHtmlTemplate(''), 'fallback');
|
|
37
|
+
assert.strictEqual(validateHtmlTemplate(null), 'fallback');
|
|
38
|
+
assert.strictEqual(validateHtmlTemplate(undefined), 'fallback');
|
|
39
|
+
});
|
|
40
|
+
void it('should reject templates shorter than 50 characters', () => {
|
|
41
|
+
assert.strictEqual(validateHtmlTemplate('<div>short</div>'), 'fallback');
|
|
42
|
+
assert.strictEqual(validateHtmlTemplate('hello world'), 'fallback');
|
|
43
|
+
});
|
|
44
|
+
void it('should reject templates without HTML structure', () => {
|
|
45
|
+
const longText = 'This is just plain text without any HTML tags at all. '.repeat(3);
|
|
46
|
+
assert.strictEqual(validateHtmlTemplate(longText), 'fallback');
|
|
47
|
+
});
|
|
48
|
+
void it('should wrap partial HTML (has div but no html tag)', () => {
|
|
49
|
+
const partial = '<div class="container"><h1>Hello World</h1><p>This is a longer paragraph with enough content.</p></div>';
|
|
50
|
+
assert.strictEqual(validateHtmlTemplate(partial), 'wrapped');
|
|
51
|
+
});
|
|
52
|
+
void it('should accept complete HTML documents', () => {
|
|
53
|
+
const full = '<html><head><style>body{margin:0}</style></head><body><div>Content that is long enough</div></body></html>';
|
|
54
|
+
assert.strictEqual(validateHtmlTemplate(full), 'valid');
|
|
55
|
+
});
|
|
56
|
+
void it('should accept HTML with body but no html tag as wrapped', () => {
|
|
57
|
+
const bodyOnly = '<body><div class="app"><h1>Dashboard</h1><p>Metrics and analytics overview panel</p></div></body>';
|
|
58
|
+
assert.strictEqual(validateHtmlTemplate(bodyOnly), 'wrapped');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
void describe('HTML Escaping', () => {
|
|
62
|
+
void it('should escape all dangerous characters', () => {
|
|
63
|
+
assert.strictEqual(escapeHtml('<script>alert("xss")</script>'), '<script>alert("xss")</script>');
|
|
64
|
+
});
|
|
65
|
+
void it('should escape ampersands', () => {
|
|
66
|
+
assert.strictEqual(escapeHtml('A & B'), 'A & B');
|
|
67
|
+
});
|
|
68
|
+
void it('should escape single quotes', () => {
|
|
69
|
+
assert.strictEqual(escapeHtml("it's"), 'it's');
|
|
70
|
+
});
|
|
71
|
+
void it('should handle empty strings', () => {
|
|
72
|
+
assert.strictEqual(escapeHtml(''), '');
|
|
73
|
+
});
|
|
74
|
+
void it('should not double-escape already escaped content', () => {
|
|
75
|
+
// First escape
|
|
76
|
+
const once = escapeHtml('<div>');
|
|
77
|
+
assert.strictEqual(once, '<div>');
|
|
78
|
+
// Double escaping should escape the ampersands
|
|
79
|
+
const twice = escapeHtml(once);
|
|
80
|
+
assert.strictEqual(twice, '&lt;div&gt;');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
// ============================================================
|
|
84
|
+
// Retry Logic
|
|
85
|
+
// ============================================================
|
|
86
|
+
void describe('Retry Backoff Calculation', () => {
|
|
87
|
+
// Re-implement the backoff formula for testing
|
|
88
|
+
function calculateBackoff(attempt, baseDelayMs) {
|
|
89
|
+
return baseDelayMs * Math.pow(2, attempt);
|
|
90
|
+
}
|
|
91
|
+
void it('should use exponential backoff', () => {
|
|
92
|
+
assert.strictEqual(calculateBackoff(0, 1000), 1000);
|
|
93
|
+
assert.strictEqual(calculateBackoff(1, 1000), 2000);
|
|
94
|
+
assert.strictEqual(calculateBackoff(2, 1000), 4000);
|
|
95
|
+
assert.strictEqual(calculateBackoff(3, 1000), 8000);
|
|
96
|
+
});
|
|
97
|
+
void it('should respect base delay', () => {
|
|
98
|
+
assert.strictEqual(calculateBackoff(0, 500), 500);
|
|
99
|
+
assert.strictEqual(calculateBackoff(1, 500), 1000);
|
|
100
|
+
assert.strictEqual(calculateBackoff(2, 500), 2000);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
// ============================================================
|
|
104
|
+
// Device Frame Auto-Detection
|
|
105
|
+
// ============================================================
|
|
106
|
+
void describe('Device Frame Auto-Detection', () => {
|
|
107
|
+
function autoDetectDeviceFrame(width, height) {
|
|
108
|
+
const aspectRatio = width / height;
|
|
109
|
+
if (aspectRatio < 0.7) {
|
|
110
|
+
return 'iphone';
|
|
111
|
+
}
|
|
112
|
+
return 'browser';
|
|
113
|
+
}
|
|
114
|
+
void it('should detect portrait mobile as iphone', () => {
|
|
115
|
+
assert.strictEqual(autoDetectDeviceFrame(390, 844), 'iphone');
|
|
116
|
+
assert.strictEqual(autoDetectDeviceFrame(375, 812), 'iphone');
|
|
117
|
+
});
|
|
118
|
+
void it('should detect landscape desktop as browser', () => {
|
|
119
|
+
assert.strictEqual(autoDetectDeviceFrame(1280, 720), 'browser');
|
|
120
|
+
assert.strictEqual(autoDetectDeviceFrame(1280, 800), 'browser');
|
|
121
|
+
assert.strictEqual(autoDetectDeviceFrame(1920, 1080), 'browser');
|
|
122
|
+
});
|
|
123
|
+
void it('should detect square-ish ratios as browser', () => {
|
|
124
|
+
assert.strictEqual(autoDetectDeviceFrame(800, 800), 'browser');
|
|
125
|
+
assert.strictEqual(autoDetectDeviceFrame(1024, 768), 'browser');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
// ============================================================
|
|
129
|
+
// Video Plan Extraction
|
|
130
|
+
// ============================================================
|
|
131
|
+
void describe('Video Plan Extraction', () => {
|
|
132
|
+
function extractVideoPlans(
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
134
|
+
contentSuggestions) {
|
|
135
|
+
const plans = [];
|
|
136
|
+
for (let i = 0; i < contentSuggestions.length; i++) {
|
|
137
|
+
const suggestion = contentSuggestions[i];
|
|
138
|
+
const videoPlan = suggestion.video_plan;
|
|
139
|
+
if (videoPlan &&
|
|
140
|
+
videoPlan.should_generate_video &&
|
|
141
|
+
videoPlan.scenes?.length > 0) {
|
|
142
|
+
plans.push({ index: i, shouldGenerate: true });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return plans;
|
|
146
|
+
}
|
|
147
|
+
void it('should extract suggestions with video plans', () => {
|
|
148
|
+
const suggestions = [
|
|
149
|
+
{
|
|
150
|
+
title: 'Blog Post',
|
|
151
|
+
video_plan: { should_generate_video: false, scenes: [] },
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
title: 'Demo Video',
|
|
155
|
+
video_plan: { should_generate_video: true, scenes: [{ order: 1 }] },
|
|
156
|
+
},
|
|
157
|
+
{ title: 'Tweet', video_plan: null },
|
|
158
|
+
];
|
|
159
|
+
const plans = extractVideoPlans(suggestions);
|
|
160
|
+
assert.strictEqual(plans.length, 1);
|
|
161
|
+
assert.strictEqual(plans[0].index, 1);
|
|
162
|
+
});
|
|
163
|
+
void it('should ignore suggestions with empty scenes', () => {
|
|
164
|
+
const suggestions = [
|
|
165
|
+
{
|
|
166
|
+
title: 'Video',
|
|
167
|
+
video_plan: { should_generate_video: true, scenes: [] },
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
const plans = extractVideoPlans(suggestions);
|
|
171
|
+
assert.strictEqual(plans.length, 0);
|
|
172
|
+
});
|
|
173
|
+
void it('should handle suggestions with no video plan', () => {
|
|
174
|
+
const suggestions = [
|
|
175
|
+
{ title: 'Post 1' },
|
|
176
|
+
{ title: 'Post 2', video_plan: undefined },
|
|
177
|
+
];
|
|
178
|
+
const plans = extractVideoPlans(suggestions);
|
|
179
|
+
assert.strictEqual(plans.length, 0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
// ============================================================
|
|
183
|
+
// Concurrency Limiter
|
|
184
|
+
// ============================================================
|
|
185
|
+
void describe('Concurrency Limiter', () => {
|
|
186
|
+
async function runWithConcurrencyLimit(tasks, limit) {
|
|
187
|
+
const results = new Array(tasks.length);
|
|
188
|
+
let nextIndex = 0;
|
|
189
|
+
async function runNext() {
|
|
190
|
+
while (nextIndex < tasks.length) {
|
|
191
|
+
const i = nextIndex++;
|
|
192
|
+
try {
|
|
193
|
+
const value = await tasks[i]();
|
|
194
|
+
results[i] = { status: 'fulfilled', value };
|
|
195
|
+
}
|
|
196
|
+
catch (reason) {
|
|
197
|
+
results[i] = { status: 'rejected', reason };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
|
|
202
|
+
await Promise.all(workers);
|
|
203
|
+
return results;
|
|
204
|
+
}
|
|
205
|
+
void it('should process all tasks and preserve order', async () => {
|
|
206
|
+
const tasks = [
|
|
207
|
+
() => Promise.resolve('a'),
|
|
208
|
+
() => Promise.resolve('b'),
|
|
209
|
+
() => Promise.resolve('c'),
|
|
210
|
+
];
|
|
211
|
+
const results = await runWithConcurrencyLimit(tasks, 2);
|
|
212
|
+
assert.strictEqual(results.length, 3);
|
|
213
|
+
assert.strictEqual(results[0].status, 'fulfilled');
|
|
214
|
+
assert.strictEqual(results[0].value, 'a');
|
|
215
|
+
assert.strictEqual(results[2].value, 'c');
|
|
216
|
+
});
|
|
217
|
+
void it('should handle rejected tasks without stopping others', async () => {
|
|
218
|
+
const tasks = [
|
|
219
|
+
() => Promise.resolve('ok'),
|
|
220
|
+
() => Promise.reject(new Error('fail')),
|
|
221
|
+
() => Promise.resolve('also ok'),
|
|
222
|
+
];
|
|
223
|
+
const results = await runWithConcurrencyLimit(tasks, 2);
|
|
224
|
+
assert.strictEqual(results[0].status, 'fulfilled');
|
|
225
|
+
assert.strictEqual(results[1].status, 'rejected');
|
|
226
|
+
assert.strictEqual(results[2].status, 'fulfilled');
|
|
227
|
+
});
|
|
228
|
+
void it('should respect concurrency limit', async () => {
|
|
229
|
+
let maxConcurrent = 0;
|
|
230
|
+
let currentConcurrent = 0;
|
|
231
|
+
const tasks = Array.from({ length: 6 }, (_, i) => async () => {
|
|
232
|
+
currentConcurrent++;
|
|
233
|
+
if (currentConcurrent > maxConcurrent) {
|
|
234
|
+
maxConcurrent = currentConcurrent;
|
|
235
|
+
}
|
|
236
|
+
await new Promise((r) => {
|
|
237
|
+
setTimeout(r, 10);
|
|
238
|
+
});
|
|
239
|
+
currentConcurrent--;
|
|
240
|
+
return i;
|
|
241
|
+
});
|
|
242
|
+
await runWithConcurrencyLimit(tasks, 2);
|
|
243
|
+
assert.ok(maxConcurrent <= 2, `Max concurrent was ${maxConcurrent}, expected <= 2`);
|
|
244
|
+
});
|
|
245
|
+
void it('should handle empty task list', async () => {
|
|
246
|
+
const results = await runWithConcurrencyLimit([], 5);
|
|
247
|
+
assert.strictEqual(results.length, 0);
|
|
248
|
+
});
|
|
249
|
+
});
|