@cifn/runner 0.0.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.
@@ -0,0 +1,957 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ MemoryStore,
4
+ MemoryQueueClient,
5
+ MemorySecFnClient,
6
+ MemoryFileFnClient,
7
+ DEFAULT_QUEUE_NAME,
8
+ createApp,
9
+ type PipelineSpec,
10
+ } from 'cifn';
11
+ import { Runner } from './runner.js';
12
+ import { MemoryLogFnClient } from './reporting/logfn-client.js';
13
+ import { DockerExecutor } from './docker-executor.js';
14
+ import { executeRunStep } from './executor/run-step.js';
15
+ import { executeCheckout } from './steps/checkout.js';
16
+ import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const singleJobPipeline: PipelineSpec = {
21
+ name: 'single',
22
+ on: { workflow_dispatch: {} },
23
+ jobs: {
24
+ build: {
25
+ 'runs-on': 'default',
26
+ steps: [{ run: 'echo hello' }, { run: 'echo world' }],
27
+ },
28
+ },
29
+ };
30
+
31
+ const twoJobPipeline: PipelineSpec = {
32
+ name: 'dag-test',
33
+ on: { workflow_dispatch: {} },
34
+ jobs: {
35
+ build: { 'runs-on': 'default', steps: [{ run: 'echo build' }] },
36
+ test: { 'runs-on': 'default', needs: ['build'], steps: [{ run: 'echo test' }] },
37
+ },
38
+ };
39
+
40
+ const failingPipeline: PipelineSpec = {
41
+ name: 'fail',
42
+ on: { workflow_dispatch: {} },
43
+ jobs: {
44
+ build: {
45
+ 'runs-on': 'default',
46
+ steps: [
47
+ { run: 'echo before' },
48
+ { run: 'exit 1' },
49
+ { run: 'echo after' },
50
+ ],
51
+ },
52
+ },
53
+ };
54
+
55
+ const TEST_API_KEY = 'runner-test-key';
56
+
57
+ async function createAuthApp(
58
+ options: { store: MemoryStore; queue: MemoryQueueClient; logClient?: MemoryLogFnClient }
59
+ ) {
60
+ const secFn = new MemorySecFnClient();
61
+ await secFn.setSecret('CIFN_API_KEYS', TEST_API_KEY);
62
+ return createApp({ ...options, secFn });
63
+ }
64
+
65
+ function authHeaders() {
66
+ return {
67
+ 'Content-Type': 'application/json',
68
+ 'Authorization': `Bearer ${TEST_API_KEY}`,
69
+ };
70
+ }
71
+
72
+ describe('executeRunStep', () => {
73
+ let workspace: string;
74
+
75
+ beforeEach(() => {
76
+ workspace = mkdtempSync(join(tmpdir(), 'cifn-test-'));
77
+ });
78
+
79
+ it('executes shell command and captures stdout', () => {
80
+ const result = executeRunStep('echo hello', workspace);
81
+ expect(result.exitCode).toBe(0);
82
+ expect(result.stdout).toContain('hello');
83
+ expect(result.lines).toContain('hello');
84
+ });
85
+
86
+ it('returns non-zero exit code for failing command', () => {
87
+ const result = executeRunStep('exit 1', workspace);
88
+ expect(result.exitCode).toBe(1);
89
+ });
90
+
91
+ it('captures stderr for failing command', () => {
92
+ const result = executeRunStep('echo err >&2 && exit 1', workspace);
93
+ expect(result.exitCode).toBe(1);
94
+ expect(result.lines).toContain('err');
95
+ });
96
+
97
+ it('runs in specified workspace directory', () => {
98
+ const result = executeRunStep('pwd', workspace);
99
+ expect(result.exitCode).toBe(0);
100
+ const { realpathSync } = require('node:fs');
101
+ expect(result.stdout.trim()).toBe(realpathSync(workspace));
102
+ });
103
+ });
104
+
105
+ describe('MemoryLogFnClient', () => {
106
+ it('stores and retrieves log entries by runId and jobKey', () => {
107
+ const logClient = new MemoryLogFnClient();
108
+ logClient.appendLines('r1', 'build', 'step-0', ['line1', 'line2']);
109
+ logClient.appendLines('r1', 'test', 'step-0', ['line3']);
110
+ logClient.appendLines('r2', 'build', 'step-0', ['line4']);
111
+
112
+ const r1Build = logClient.getLines('r1', 'build');
113
+ expect(r1Build).toHaveLength(2);
114
+ expect(r1Build[0].line).toBe('line1');
115
+ expect(r1Build[1].line).toBe('line2');
116
+
117
+ const r1Test = logClient.getLines('r1', 'test');
118
+ expect(r1Test).toHaveLength(1);
119
+
120
+ const r1All = logClient.getAllLines('r1');
121
+ expect(r1All).toHaveLength(3);
122
+ });
123
+ });
124
+
125
+ describe('Runner - single job with run steps', () => {
126
+ let store: MemoryStore;
127
+ let queue: MemoryQueueClient;
128
+ let logClient: MemoryLogFnClient;
129
+ let runner: Runner;
130
+
131
+ beforeEach(() => {
132
+ store = new MemoryStore();
133
+ queue = new MemoryQueueClient();
134
+ logClient = new MemoryLogFnClient();
135
+ runner = new Runner({ store, queue, logClient });
136
+ });
137
+
138
+ it('end-to-end: runs one job with two run steps (echo hello, echo world)', async () => {
139
+ const run = store.createRun({
140
+ pipelineSpec: singleJobPipeline,
141
+ trigger: { type: 'workflow_dispatch' },
142
+ });
143
+
144
+ runner.registerPipelineSpec(run.id, singleJobPipeline);
145
+
146
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
147
+ runId: run.id,
148
+ jobKey: 'build',
149
+ jobSpec: singleJobPipeline.jobs.build,
150
+ });
151
+
152
+ const processed = await runner.processAllJobs();
153
+ expect(processed).toBe(1);
154
+
155
+ const updatedRun = store.getRun(run.id)!;
156
+ expect(updatedRun.status).toBe('success');
157
+
158
+ const job = updatedRun.jobs.find(j => j.jobKey === 'build')!;
159
+ expect(job.status).toBe('success');
160
+ expect(job.startedAt).toBeDefined();
161
+ expect(job.completedAt).toBeDefined();
162
+
163
+ expect(job.steps[0].status).toBe('success');
164
+ expect(job.steps[1].status).toBe('success');
165
+
166
+ const logs = logClient.getLines(run.id, 'build');
167
+ const logLines = logs.map(l => l.line);
168
+ expect(logLines.some(l => l.includes('hello'))).toBe(true);
169
+ expect(logLines.some(l => l.includes('world'))).toBe(true);
170
+ });
171
+
172
+ it('TV-OBS-001: events logged (job started, step started/completed, job completed)', async () => {
173
+ const run = store.createRun({
174
+ pipelineSpec: singleJobPipeline,
175
+ trigger: { type: 'workflow_dispatch' },
176
+ });
177
+
178
+ runner.registerPipelineSpec(run.id, singleJobPipeline);
179
+
180
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
181
+ runId: run.id,
182
+ jobKey: 'build',
183
+ jobSpec: singleJobPipeline.jobs.build,
184
+ });
185
+
186
+ await runner.processAllJobs();
187
+
188
+ const logs = logClient.getLines(run.id, 'build');
189
+ const logLines = logs.map(l => l.line);
190
+ expect(logLines.some(l => l.includes('Job "build" started'))).toBe(true);
191
+ expect(logLines.some(l => l.includes('step-0') && l.includes('started'))).toBe(true);
192
+ expect(logLines.some(l => l.includes('step-0') && l.includes('completed successfully'))).toBe(true);
193
+ expect(logLines.some(l => l.includes('step-1') && l.includes('started'))).toBe(true);
194
+ expect(logLines.some(l => l.includes('step-1') && l.includes('completed successfully'))).toBe(true);
195
+ expect(logLines.some(l => l.includes('Job "build" completed successfully'))).toBe(true);
196
+ });
197
+
198
+ it('RUNNER-003: runner only executes steps from job payload', async () => {
199
+ const run = store.createRun({
200
+ pipelineSpec: singleJobPipeline,
201
+ trigger: { type: 'workflow_dispatch' },
202
+ });
203
+
204
+ runner.registerPipelineSpec(run.id, singleJobPipeline);
205
+
206
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
207
+ runId: run.id,
208
+ jobKey: 'build',
209
+ jobSpec: singleJobPipeline.jobs.build,
210
+ });
211
+
212
+ await runner.processAllJobs();
213
+
214
+ const job = store.getRun(run.id)!.jobs.find(j => j.jobKey === 'build')!;
215
+ expect(job.steps).toHaveLength(2);
216
+ job.steps.forEach(s => {
217
+ expect(['success', 'failure', 'skipped']).toContain(s.status);
218
+ });
219
+ });
220
+ });
221
+
222
+ describe('Runner - step failure', () => {
223
+ let store: MemoryStore;
224
+ let queue: MemoryQueueClient;
225
+ let logClient: MemoryLogFnClient;
226
+ let runner: Runner;
227
+
228
+ beforeEach(() => {
229
+ store = new MemoryStore();
230
+ queue = new MemoryQueueClient();
231
+ logClient = new MemoryLogFnClient();
232
+ runner = new Runner({ store, queue, logClient });
233
+ });
234
+
235
+ it('marks job as failed on step failure and skips remaining steps', async () => {
236
+ const run = store.createRun({
237
+ pipelineSpec: failingPipeline,
238
+ trigger: { type: 'workflow_dispatch' },
239
+ });
240
+
241
+ runner.registerPipelineSpec(run.id, failingPipeline);
242
+
243
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
244
+ runId: run.id,
245
+ jobKey: 'build',
246
+ jobSpec: failingPipeline.jobs.build,
247
+ });
248
+
249
+ await runner.processAllJobs();
250
+
251
+ const updatedRun = store.getRun(run.id)!;
252
+ expect(updatedRun.status).toBe('failure');
253
+
254
+ const job = updatedRun.jobs.find(j => j.jobKey === 'build')!;
255
+ expect(job.status).toBe('failure');
256
+
257
+ expect(job.steps[0].status).toBe('success');
258
+ expect(job.steps[1].status).toBe('failure');
259
+ expect(job.steps[2].status).toBe('skipped');
260
+ });
261
+
262
+ it('TV-EXPR-001: failure() step runs after failure; default step skipped', async () => {
263
+ const fileFn = new MemoryFileFnClient();
264
+ const storeWithArtifacts = new MemoryStore();
265
+ const queueExpr = new MemoryQueueClient();
266
+ const logClientExpr = new MemoryLogFnClient();
267
+ const runnerExpr = new Runner({
268
+ store: storeWithArtifacts,
269
+ queue: queueExpr,
270
+ logClient: logClientExpr,
271
+ fileFnClient: fileFn,
272
+ artifactStore: { addArtifact: (runId, a) => storeWithArtifacts.addArtifact(runId, a) },
273
+ });
274
+
275
+ const pipeline: PipelineSpec = {
276
+ name: 'if',
277
+ on: { workflow_dispatch: {} },
278
+ jobs: {
279
+ build: {
280
+ 'runs-on': 'default',
281
+ steps: [
282
+ { run: 'node -e "process.exit(1)"' },
283
+ {
284
+ uses: 'artifact/upload',
285
+ if: 'failure()',
286
+ with: { name: 'fail-info', path: './' },
287
+ },
288
+ { run: 'echo should-skip-by-default' },
289
+ ],
290
+ },
291
+ },
292
+ };
293
+
294
+ const run = storeWithArtifacts.createRun({
295
+ pipelineSpec: pipeline,
296
+ trigger: { type: 'workflow_dispatch', payload: { github: { ref: 'refs/heads/main' } } },
297
+ });
298
+
299
+ runnerExpr.registerPipelineSpec(run.id, pipeline);
300
+
301
+ await queueExpr.enqueue(DEFAULT_QUEUE_NAME, {
302
+ runId: run.id,
303
+ jobKey: 'build',
304
+ jobSpec: pipeline.jobs.build,
305
+ pipelineRef: { repo: 'https://github.com/org/repo', ref: 'main' },
306
+ });
307
+
308
+ await runnerExpr.processAllJobs();
309
+
310
+ const updatedRun = storeWithArtifacts.getRun(run.id)!;
311
+ expect(updatedRun.status).toBe('failure');
312
+
313
+ const job = updatedRun.jobs.find(j => j.jobKey === 'build')!;
314
+ expect(job.status).toBe('failure');
315
+ expect(job.steps[0].status).toBe('failure');
316
+ expect(job.steps[1].status).toBe('success');
317
+ expect(job.steps[2].status).toBe('skipped');
318
+ });
319
+ });
320
+
321
+ describe('TV-EXPR-002: Job if false => skipped and satisfies needs', () => {
322
+ it('job with if false is skipped; dependent job still runs', async () => {
323
+ const store = new MemoryStore();
324
+ const queue = new MemoryQueueClient();
325
+ const logClient = new MemoryLogFnClient();
326
+ const runner = new Runner({ store, queue, logClient });
327
+
328
+ const pipeline: PipelineSpec = {
329
+ name: 'job-if',
330
+ on: { workflow_dispatch: {} },
331
+ jobs: {
332
+ a: {
333
+ 'runs-on': 'default',
334
+ if: "github.ref == 'refs/heads/nope'",
335
+ steps: [{ run: 'echo a' }],
336
+ },
337
+ b: {
338
+ 'runs-on': 'default',
339
+ needs: ['a'],
340
+ steps: [{ run: 'echo b' }],
341
+ },
342
+ },
343
+ };
344
+
345
+ const run = store.createRun({
346
+ pipelineSpec: pipeline,
347
+ trigger: { type: 'workflow_dispatch', payload: { github: { ref: 'refs/heads/main' } } },
348
+ });
349
+
350
+ runner.registerPipelineSpec(run.id, pipeline);
351
+
352
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
353
+ runId: run.id,
354
+ jobKey: 'a',
355
+ jobSpec: pipeline.jobs.a,
356
+ pipelineRef: { repo: 'https://github.com/org/repo', ref: 'main' },
357
+ });
358
+
359
+ const processed = await runner.processAllJobs();
360
+
361
+ const updatedRun = store.getRun(run.id)!;
362
+ const jobA = updatedRun.jobs.find(j => j.jobKey === 'a')!;
363
+ const jobB = updatedRun.jobs.find(j => j.jobKey === 'b')!;
364
+
365
+ expect(jobA.status).toBe('skipped');
366
+ expect(jobB.status).toBe('success');
367
+ expect(processed).toBe(2);
368
+ });
369
+ });
370
+
371
+ describe('Runner - DAG: job order respects needs (TV-RUN-002)', () => {
372
+ let store: MemoryStore;
373
+ let queue: MemoryQueueClient;
374
+ let logClient: MemoryLogFnClient;
375
+ let runner: Runner;
376
+
377
+ beforeEach(() => {
378
+ store = new MemoryStore();
379
+ queue = new MemoryQueueClient();
380
+ logClient = new MemoryLogFnClient();
381
+ runner = new Runner({ store, queue, logClient });
382
+ });
383
+
384
+ it('build runs first, then test (dependent job enqueued after build completes)', async () => {
385
+ const run = store.createRun({
386
+ pipelineSpec: twoJobPipeline,
387
+ trigger: { type: 'workflow_dispatch' },
388
+ });
389
+
390
+ runner.registerPipelineSpec(run.id, twoJobPipeline);
391
+
392
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
393
+ runId: run.id,
394
+ jobKey: 'build',
395
+ jobSpec: twoJobPipeline.jobs.build,
396
+ });
397
+
398
+ const first = await runner.processNextJob();
399
+ expect(first).toBe(true);
400
+
401
+ const afterBuild = store.getRun(run.id)!;
402
+ const buildJob = afterBuild.jobs.find(j => j.jobKey === 'build')!;
403
+ expect(buildJob.status).toBe('success');
404
+
405
+ expect(queue.size(DEFAULT_QUEUE_NAME)).toBe(1);
406
+ const nextPayload = queue.peek(DEFAULT_QUEUE_NAME);
407
+ expect(nextPayload[0].jobKey).toBe('test');
408
+
409
+ const second = await runner.processNextJob();
410
+ expect(second).toBe(true);
411
+
412
+ const finalRun = store.getRun(run.id)!;
413
+ expect(finalRun.status).toBe('success');
414
+ const testJob = finalRun.jobs.find(j => j.jobKey === 'test')!;
415
+ expect(testJob.status).toBe('success');
416
+ });
417
+
418
+ it('dependent job not enqueued when build fails', async () => {
419
+ const failBuildPipeline: PipelineSpec = {
420
+ name: 'fail-build',
421
+ on: { workflow_dispatch: {} },
422
+ jobs: {
423
+ build: { 'runs-on': 'default', steps: [{ run: 'exit 1' }] },
424
+ test: { 'runs-on': 'default', needs: ['build'], steps: [{ run: 'echo test' }] },
425
+ },
426
+ };
427
+
428
+ const run = store.createRun({
429
+ pipelineSpec: failBuildPipeline,
430
+ trigger: { type: 'workflow_dispatch' },
431
+ });
432
+
433
+ runner.registerPipelineSpec(run.id, failBuildPipeline);
434
+
435
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
436
+ runId: run.id,
437
+ jobKey: 'build',
438
+ jobSpec: failBuildPipeline.jobs.build,
439
+ });
440
+
441
+ await runner.processAllJobs();
442
+
443
+ const updatedRun = store.getRun(run.id)!;
444
+ expect(updatedRun.status).toBe('failure');
445
+
446
+ expect(queue.size(DEFAULT_QUEUE_NAME)).toBe(0);
447
+ });
448
+ });
449
+
450
+ describe('Runner - RUN-003: Step order', () => {
451
+ let store: MemoryStore;
452
+ let queue: MemoryQueueClient;
453
+ let logClient: MemoryLogFnClient;
454
+ let runner: Runner;
455
+
456
+ beforeEach(() => {
457
+ store = new MemoryStore();
458
+ queue = new MemoryQueueClient();
459
+ logClient = new MemoryLogFnClient();
460
+ runner = new Runner({ store, queue, logClient });
461
+ });
462
+
463
+ it('steps execute in array order', async () => {
464
+ const orderedPipeline: PipelineSpec = {
465
+ name: 'ordered',
466
+ on: { workflow_dispatch: {} },
467
+ jobs: {
468
+ build: {
469
+ 'runs-on': 'default',
470
+ steps: [{ run: 'echo first' }, { run: 'echo second' }, { run: 'echo third' }],
471
+ },
472
+ },
473
+ };
474
+
475
+ const run = store.createRun({
476
+ pipelineSpec: orderedPipeline,
477
+ trigger: { type: 'workflow_dispatch' },
478
+ });
479
+
480
+ runner.registerPipelineSpec(run.id, orderedPipeline);
481
+
482
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
483
+ runId: run.id,
484
+ jobKey: 'build',
485
+ jobSpec: orderedPipeline.jobs.build,
486
+ });
487
+
488
+ await runner.processAllJobs();
489
+
490
+ const logs = logClient.getLines(run.id, 'build');
491
+ const outputLines = logs.map(l => l.line).filter(l => ['first', 'second', 'third'].includes(l));
492
+ expect(outputLines).toEqual(['first', 'second', 'third']);
493
+ });
494
+ });
495
+
496
+ describe('Runner + API logs endpoint (TV-API-012)', () => {
497
+ it('GET /runs/:runId/jobs/:jobKey/logs returns log lines after runner execution', async () => {
498
+ const store = new MemoryStore();
499
+ const queue = new MemoryQueueClient();
500
+ const logClient = new MemoryLogFnClient();
501
+
502
+ const { app } = await createAuthApp({ store, queue, logClient });
503
+ const runner = new Runner({ store, queue, logClient });
504
+
505
+ const createRes = await app.request('http://localhost/api/v1/runs', {
506
+ method: 'POST',
507
+ headers: authHeaders(),
508
+ body: JSON.stringify({
509
+ trigger: { type: 'workflow_dispatch' },
510
+ pipelineSpec: singleJobPipeline,
511
+ }),
512
+ });
513
+ const { data } = await createRes.json() as { data: { runId: string } };
514
+ const runId = data.runId;
515
+
516
+ runner.registerPipelineSpec(runId, singleJobPipeline);
517
+
518
+ await runner.processAllJobs();
519
+
520
+ const logsRes = await app.request(`http://localhost/api/v1/runs/${runId}/jobs/build/logs`, {
521
+ headers: authHeaders(),
522
+ });
523
+ expect(logsRes.status).toBe(200);
524
+ const logsBody = await logsRes.json() as { ok: boolean; data: { lines: Array<{ line: string }> } };
525
+ expect(logsBody.ok).toBe(true);
526
+ const lineTexts = logsBody.data.lines.map(l => l.line);
527
+ expect(lineTexts.some(l => l.includes('hello'))).toBe(true);
528
+ expect(lineTexts.some(l => l.includes('world'))).toBe(true);
529
+ });
530
+
531
+ it('TV-API-013: GET logs returns 404 for nonexistent job', async () => {
532
+ const store = new MemoryStore();
533
+ const queue = new MemoryQueueClient();
534
+ const logClient = new MemoryLogFnClient();
535
+
536
+ const { app } = await createAuthApp({ store, queue, logClient });
537
+
538
+ const createRes = await app.request('http://localhost/api/v1/runs', {
539
+ method: 'POST',
540
+ headers: authHeaders(),
541
+ body: JSON.stringify({
542
+ trigger: { type: 'workflow_dispatch' },
543
+ pipelineSpec: singleJobPipeline,
544
+ }),
545
+ });
546
+ const { data } = await createRes.json() as { data: { runId: string } };
547
+
548
+ const logsRes = await app.request(`http://localhost/api/v1/runs/${data.runId}/jobs/nonexistent/logs`, {
549
+ headers: authHeaders(),
550
+ });
551
+ expect(logsRes.status).toBe(404);
552
+ });
553
+
554
+ it('GET logs returns 404 for nonexistent runId', async () => {
555
+ const store = new MemoryStore();
556
+ const queue = new MemoryQueueClient();
557
+ const logClient = new MemoryLogFnClient();
558
+
559
+ const { app } = await createAuthApp({ store, queue, logClient });
560
+
561
+ const logsRes = await app.request('http://localhost/api/v1/runs/nonexistent/jobs/build/logs', {
562
+ headers: authHeaders(),
563
+ });
564
+ expect(logsRes.status).toBe(404);
565
+ });
566
+ });
567
+
568
+ describe('RUNNER-001: Default runner executes jobs with runs-on: default', () => {
569
+ it('processes job for runs-on: default', async () => {
570
+ const store = new MemoryStore();
571
+ const queue = new MemoryQueueClient();
572
+ const logClient = new MemoryLogFnClient();
573
+ const runner = new Runner({ store, queue, logClient });
574
+
575
+ const run = store.createRun({
576
+ pipelineSpec: singleJobPipeline,
577
+ trigger: { type: 'workflow_dispatch' },
578
+ });
579
+
580
+ runner.registerPipelineSpec(run.id, singleJobPipeline);
581
+
582
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
583
+ runId: run.id,
584
+ jobKey: 'build',
585
+ jobSpec: singleJobPipeline.jobs.build,
586
+ });
587
+
588
+ await runner.processAllJobs();
589
+
590
+ const updatedRun = store.getRun(run.id)!;
591
+ expect(updatedRun.status).toBe('success');
592
+ });
593
+
594
+ it('runs a default job in docker when job image is specified', async () => {
595
+ const store = new MemoryStore();
596
+ const queue = new MemoryQueueClient();
597
+ const logClient = new MemoryLogFnClient();
598
+ const capturedArgs: string[][] = [];
599
+ const dockerExecutor = new DockerExecutor({
600
+ run(args) {
601
+ capturedArgs.push(args);
602
+ return {
603
+ status: 0,
604
+ stdout: 'inside-container\n',
605
+ stderr: '',
606
+ };
607
+ },
608
+ });
609
+ const runner = new Runner({ store, queue, logClient, dockerExecutor });
610
+
611
+ const dockerPipeline: PipelineSpec = {
612
+ name: 'docker-default',
613
+ on: { workflow_dispatch: {} },
614
+ jobs: {
615
+ build: {
616
+ 'runs-on': 'default',
617
+ image: 'node:20',
618
+ steps: [{ run: 'echo "inside-container"' }],
619
+ },
620
+ },
621
+ };
622
+
623
+ const run = store.createRun({
624
+ pipelineSpec: dockerPipeline,
625
+ trigger: { type: 'workflow_dispatch' },
626
+ });
627
+ runner.registerPipelineSpec(run.id, dockerPipeline);
628
+
629
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
630
+ runId: run.id,
631
+ jobKey: 'build',
632
+ jobSpec: dockerPipeline.jobs.build,
633
+ });
634
+
635
+ await runner.processAllJobs();
636
+
637
+ const updatedRun = store.getRun(run.id)!;
638
+ expect(updatedRun.status).toBe('success');
639
+ expect(capturedArgs).toHaveLength(1);
640
+ expect(capturedArgs[0]).toContain('run');
641
+ expect(capturedArgs[0]).toContain('node:20');
642
+ expect(capturedArgs[0]).toContain('echo "inside-container"');
643
+ const lines = logClient.getLines(run.id, 'build').map(entry => entry.line);
644
+ expect(lines.some(line => line.includes('inside-container'))).toBe(true);
645
+ });
646
+
647
+ it('does not claim jobs with mismatched runs-on labels', async () => {
648
+ const store = new MemoryStore();
649
+ const queue = new MemoryQueueClient();
650
+ const logClient = new MemoryLogFnClient();
651
+ const runner = new Runner({ store, queue, logClient, labels: ['default'] });
652
+
653
+ const hostRunnerJobPipeline: PipelineSpec = {
654
+ name: 'hostfn-only',
655
+ on: { workflow_dispatch: {} },
656
+ jobs: {
657
+ deploy: {
658
+ 'runs-on': 'hostfn-runner',
659
+ steps: [{ run: 'echo deploy' }],
660
+ },
661
+ },
662
+ };
663
+ const run = store.createRun({
664
+ pipelineSpec: hostRunnerJobPipeline,
665
+ trigger: { type: 'workflow_dispatch' },
666
+ });
667
+ runner.registerPipelineSpec(run.id, hostRunnerJobPipeline);
668
+
669
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
670
+ runId: run.id,
671
+ jobKey: 'deploy',
672
+ jobSpec: hostRunnerJobPipeline.jobs.deploy,
673
+ });
674
+
675
+ const processed = await runner.processAllJobs();
676
+ expect(processed).toBe(0);
677
+ expect(queue.size(DEFAULT_QUEUE_NAME)).toBe(1);
678
+ const updatedRun = store.getRun(run.id)!;
679
+ expect(updatedRun.jobs.find(j => j.jobKey === 'deploy')?.status).toBe('pending');
680
+ });
681
+ });
682
+
683
+ describe('RUNNER-005: Runner reports logs and status', () => {
684
+ it('step logs reported to logfn client, job/run status updated in store', async () => {
685
+ const store = new MemoryStore();
686
+ const queue = new MemoryQueueClient();
687
+ const logClient = new MemoryLogFnClient();
688
+ const runner = new Runner({ store, queue, logClient });
689
+
690
+ const run = store.createRun({
691
+ pipelineSpec: singleJobPipeline,
692
+ trigger: { type: 'workflow_dispatch' },
693
+ });
694
+
695
+ runner.registerPipelineSpec(run.id, singleJobPipeline);
696
+
697
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
698
+ runId: run.id,
699
+ jobKey: 'build',
700
+ jobSpec: singleJobPipeline.jobs.build,
701
+ });
702
+
703
+ await runner.processAllJobs();
704
+
705
+ const allLogs = logClient.getAllLines(run.id);
706
+ expect(allLogs.length).toBeGreaterThan(0);
707
+
708
+ const updatedRun = store.getRun(run.id)!;
709
+ expect(updatedRun.status).toBe('success');
710
+ expect(updatedRun.jobs[0].status).toBe('success');
711
+ updatedRun.jobs[0].steps.forEach(s => expect(s.status).toBe('success'));
712
+ });
713
+ });
714
+
715
+ describe('executeCheckout', () => {
716
+ let workspace: string;
717
+
718
+ beforeEach(() => {
719
+ workspace = mkdtempSync(join(tmpdir(), 'cifn-checkout-test-'));
720
+ });
721
+
722
+ it('TV-INT-005: clones a public repo at ref into workspace', () => {
723
+ const result = executeCheckout({
724
+ repo: 'https://github.com/octocat/Hello-World.git',
725
+ ref: 'master',
726
+ workspace,
727
+ });
728
+ expect(result.success).toBe(true);
729
+ expect(result.lines.some(l => l.includes('Checkout complete'))).toBe(true);
730
+
731
+ const files = readdirSync(workspace);
732
+ expect(files.length).toBeGreaterThan(0);
733
+ expect(files).toContain('README');
734
+ });
735
+
736
+ it('TV-INT-005 negative: invalid ref fails checkout', () => {
737
+ const result = executeCheckout({
738
+ repo: 'https://github.com/octocat/Hello-World.git',
739
+ ref: 'nonexistent-branch-xyz-999',
740
+ workspace,
741
+ });
742
+ expect(result.success).toBe(false);
743
+ expect(result.error).toBeDefined();
744
+ });
745
+
746
+ it('invalid repo URL fails checkout', () => {
747
+ const result = executeCheckout({
748
+ repo: 'https://github.com/nonexistent-org-xyz/nonexistent-repo-abc.git',
749
+ ref: 'main',
750
+ workspace,
751
+ });
752
+ expect(result.success).toBe(false);
753
+ expect(result.error).toBeDefined();
754
+ });
755
+ });
756
+
757
+ describe('Runner with checkout step', () => {
758
+ it('TV-INT-005: checkout step populates workspace for subsequent run step', async () => {
759
+ const store = new MemoryStore();
760
+ const queue = new MemoryQueueClient();
761
+ const logClient = new MemoryLogFnClient();
762
+ const runner = new Runner({ store, queue, logClient, cleanWorkspace: true });
763
+
764
+ const checkoutPipeline: PipelineSpec = {
765
+ name: 'checkout-test',
766
+ on: { workflow_dispatch: {} },
767
+ jobs: {
768
+ build: {
769
+ 'runs-on': 'default',
770
+ steps: [
771
+ { uses: 'checkout', with: { repository: 'https://github.com/octocat/Hello-World.git', ref: 'master' } },
772
+ { run: 'ls -la && cat README' },
773
+ ],
774
+ },
775
+ },
776
+ };
777
+
778
+ const run = store.createRun({
779
+ pipelineSpec: checkoutPipeline,
780
+ trigger: { type: 'workflow_dispatch' },
781
+ });
782
+
783
+ runner.registerPipelineSpec(run.id, checkoutPipeline);
784
+
785
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
786
+ runId: run.id,
787
+ jobKey: 'build',
788
+ jobSpec: checkoutPipeline.jobs.build,
789
+ pipelineRef: { repo: 'https://github.com/octocat/Hello-World.git', ref: 'master' },
790
+ });
791
+
792
+ await runner.processAllJobs();
793
+
794
+ const updatedRun = store.getRun(run.id)!;
795
+ expect(updatedRun.status).toBe('success');
796
+
797
+ const job = updatedRun.jobs.find(j => j.jobKey === 'build')!;
798
+ expect(job.steps[0].status).toBe('success');
799
+ expect(job.steps[1].status).toBe('success');
800
+
801
+ const logs = logClient.getLines(run.id, 'build');
802
+ const logLines = logs.map(l => l.line);
803
+ expect(logLines.some(l => l.includes('Checkout complete'))).toBe(true);
804
+ expect(logLines.some(l => l.includes('README'))).toBe(true);
805
+ });
806
+
807
+ it('checkout step uses pipelineRef when no with.repository', async () => {
808
+ const store = new MemoryStore();
809
+ const queue = new MemoryQueueClient();
810
+ const logClient = new MemoryLogFnClient();
811
+ const runner = new Runner({ store, queue, logClient, cleanWorkspace: true });
812
+
813
+ const checkoutPipeline: PipelineSpec = {
814
+ name: 'checkout-ref-test',
815
+ on: { workflow_dispatch: {} },
816
+ jobs: {
817
+ build: {
818
+ 'runs-on': 'default',
819
+ steps: [
820
+ { uses: 'checkout' },
821
+ { run: 'cat README' },
822
+ ],
823
+ },
824
+ },
825
+ };
826
+
827
+ const run = store.createRun({
828
+ pipelineSpec: checkoutPipeline,
829
+ trigger: { type: 'workflow_dispatch' },
830
+ });
831
+
832
+ runner.registerPipelineSpec(run.id, checkoutPipeline);
833
+
834
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
835
+ runId: run.id,
836
+ jobKey: 'build',
837
+ jobSpec: checkoutPipeline.jobs.build,
838
+ pipelineRef: { repo: 'https://github.com/octocat/Hello-World.git', ref: 'master' },
839
+ });
840
+
841
+ await runner.processAllJobs();
842
+
843
+ const updatedRun = store.getRun(run.id)!;
844
+ expect(updatedRun.status).toBe('success');
845
+ });
846
+ });
847
+
848
+ describe('TV-INT-006: testfn/run uses SDK and produces JSON report', () => {
849
+ let workspace: string;
850
+
851
+ beforeEach(() => {
852
+ workspace = mkdtempSync(join(tmpdir(), 'cifn-testfn-test-'));
853
+ });
854
+
855
+ it('testfn/run step uses testfn SDK with vitest framework', async () => {
856
+ const store = new MemoryStore();
857
+ const queue = new MemoryQueueClient();
858
+ const logClient = new MemoryLogFnClient();
859
+ const runner = new Runner({ store, queue, logClient, cleanWorkspace: false });
860
+
861
+ const testFnPipeline: PipelineSpec = {
862
+ name: 'testfn-test',
863
+ on: { workflow_dispatch: {} },
864
+ jobs: {
865
+ test: {
866
+ 'runs-on': 'default',
867
+ steps: [
868
+ {
869
+ uses: 'testfn/run',
870
+ with: {
871
+ framework: 'vitest',
872
+ testPattern: './tests/**/*.test.ts',
873
+ reporter: 'json',
874
+ outputPath: './testfn-results.json',
875
+ },
876
+ },
877
+ ],
878
+ },
879
+ },
880
+ };
881
+
882
+ const run = store.createRun({
883
+ pipelineSpec: testFnPipeline,
884
+ trigger: { type: 'workflow_dispatch' },
885
+ });
886
+
887
+ runner.registerPipelineSpec(run.id, testFnPipeline);
888
+
889
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
890
+ runId: run.id,
891
+ jobKey: 'test',
892
+ jobSpec: testFnPipeline.jobs.test,
893
+ });
894
+
895
+ await runner.processAllJobs();
896
+
897
+ const updatedRun = store.getRun(run.id)!;
898
+ const job = updatedRun.jobs.find(j => j.jobKey === 'test')!;
899
+
900
+ const logs = logClient.getLines(run.id, 'test');
901
+ const logLines = logs.map(l => l.line);
902
+ expect(logLines.some(l => l.includes('Running tests with testfn SDK'))).toBe(true);
903
+ expect(logLines.some(l => l.includes('framework: vitest'))).toBe(true);
904
+ });
905
+
906
+ it('testfn/run with invalid framework fails deterministically', async () => {
907
+ const store = new MemoryStore();
908
+ const queue = new MemoryQueueClient();
909
+ const logClient = new MemoryLogFnClient();
910
+ const runner = new Runner({ store, queue, logClient, cleanWorkspace: false });
911
+
912
+ const testFnPipeline: PipelineSpec = {
913
+ name: 'testfn-invalid',
914
+ on: { workflow_dispatch: {} },
915
+ jobs: {
916
+ test: {
917
+ 'runs-on': 'default',
918
+ steps: [
919
+ {
920
+ uses: 'testfn/run',
921
+ with: {
922
+ framework: 'notreal',
923
+ testPattern: './tests/**/*.test.ts',
924
+ },
925
+ },
926
+ ],
927
+ },
928
+ },
929
+ };
930
+
931
+ const run = store.createRun({
932
+ pipelineSpec: testFnPipeline,
933
+ trigger: { type: 'workflow_dispatch' },
934
+ });
935
+
936
+ runner.registerPipelineSpec(run.id, testFnPipeline);
937
+
938
+ await queue.enqueue(DEFAULT_QUEUE_NAME, {
939
+ runId: run.id,
940
+ jobKey: 'test',
941
+ jobSpec: testFnPipeline.jobs.test,
942
+ });
943
+
944
+ await runner.processAllJobs();
945
+
946
+ const updatedRun = store.getRun(run.id)!;
947
+ expect(updatedRun.status).toBe('failure');
948
+
949
+ const job = updatedRun.jobs.find(j => j.jobKey === 'test')!;
950
+ expect(job.status).toBe('failure');
951
+ expect(job.steps[0].status).toBe('failure');
952
+
953
+ const logs = logClient.getLines(run.id, 'test');
954
+ const logLines = logs.map(l => l.line);
955
+ expect(logLines.some(l => l.includes('Unsupported framework'))).toBe(true);
956
+ });
957
+ });