worker.test.js (21973B)
1 // test/worker.test.js - worker units that need no container runtime 2 // 3 // The full loop against real Docker lives in worker-docker.test.js. 4 5 import test from 'node:test'; 6 import assert from 'node:assert/strict'; 7 import fs from 'node:fs/promises'; 8 import os from 'node:os'; 9 import path from 'node:path'; 10 import { spawn } from 'node:child_process'; 11 import { fileURLToPath } from 'node:url'; 12 import { EventEmitter } from 'node:events'; 13 import { PassThrough, Readable } from 'node:stream'; 14 import { gzipSync } from 'node:zlib'; 15 import { createServer } from 'node:http'; 16 import { loadWorkerConfig, featureNames } from '../src/worker/config.js'; 17 import { buildScript, shellQuote } from '../src/worker/script.js'; 18 import { containerName, createRuntime } from '../src/worker/docker.js'; 19 import { createLogStream } from '../src/worker/logstream.js'; 20 import { resolveFeatures, patternPrefix, copyOutPaths, runTask } from '../src/worker/task.js'; 21 import { collectArtifacts, shouldCollect } from '../src/worker/artifacts.js'; 22 23 async function tempDir(prefix = 'conductor-worker-') { 24 return fs.mkdtemp(path.join(os.tmpdir(), prefix)); 25 } 26 27 async function withConfigEnv(env, fn) { 28 const saved = {}; 29 for (const key of Object.keys(process.env)) { 30 if (key.startsWith('CONDUCTOR_')) { 31 saved[key] = process.env[key]; 32 delete process.env[key]; 33 } 34 } 35 Object.assign(process.env, env); 36 try { 37 return await fn(); 38 } finally { 39 for (const key of Object.keys(process.env)) { 40 if (key.startsWith('CONDUCTOR_')) delete process.env[key]; 41 } 42 Object.assign(process.env, saved); 43 } 44 } 45 46 test('worker config loads from json and reports features', async () => { 47 const dir = await tempDir(); 48 const file = path.join(dir, 'worker.json'); 49 await fs.writeFile(file, JSON.stringify({ 50 conductor_url: 'https://ci.example.com/', 51 name: 'pi', 52 token: 'abc', 53 arches: ['aarch64'], 54 features: { 55 dind: { privileged: true }, 56 'sign-key': { mounts: ['/srv/k:/keys/k:ro'], env: { KEY: '/keys/k' } }, 57 }, 58 })); 59 60 const cfg = await withConfigEnv({}, () => loadWorkerConfig(file)); 61 assert.equal(cfg.conductor_url, 'https://ci.example.com'); 62 assert.deepEqual(featureNames(cfg), ['dind', 'sign-key']); 63 assert.equal(cfg.features.dind.privileged, true); 64 assert.deepEqual(cfg.features['sign-key'].mounts, ['/srv/k:/keys/k:ro']); 65 await fs.rm(dir, { recursive: true, force: true }); 66 }); 67 68 test('worker config reads the token from a file', async () => { 69 const dir = await tempDir(); 70 await fs.writeFile(path.join(dir, 'token'), 'secret-token\n'); 71 const file = path.join(dir, 'worker.json'); 72 await fs.writeFile(file, JSON.stringify({ token_file: path.join(dir, 'token') })); 73 74 const cfg = await withConfigEnv({}, () => loadWorkerConfig(file)); 75 assert.equal(cfg.token, 'secret-token'); 76 await fs.rm(dir, { recursive: true, force: true }); 77 }); 78 79 test('environment overrides the worker config file', async () => { 80 const dir = await tempDir(); 81 const file = path.join(dir, 'worker.json'); 82 await fs.writeFile(file, JSON.stringify({ token: 'x', arches: ['x86_64'], concurrency: 1 })); 83 84 const cfg = await withConfigEnv({ 85 CONDUCTOR_WORKER_ARCHES: 'aarch64,riscv64', 86 CONDUCTOR_WORKER_CONCURRENCY: '4', 87 }, () => loadWorkerConfig(file)); 88 89 assert.deepEqual(cfg.arches, ['aarch64', 'riscv64']); 90 assert.equal(cfg.concurrency, 4); 91 await fs.rm(dir, { recursive: true, force: true }); 92 }); 93 94 test('worker config without a token is rejected', async () => { 95 const dir = await tempDir(); 96 const file = path.join(dir, 'worker.json'); 97 await fs.writeFile(file, JSON.stringify({ conductor_url: 'http://x' })); 98 await assert.rejects(withConfigEnv({}, () => loadWorkerConfig(file)), /no worker token/); 99 await fs.rm(dir, { recursive: true, force: true }); 100 }); 101 102 test('a malformed feature is reported with its path', async () => { 103 const dir = await tempDir(); 104 const file = path.join(dir, 'worker.json'); 105 await fs.writeFile(file, JSON.stringify({ 106 token: 'x', 107 features: { bad: { mounts: ['no-colon'], nonsense: 1 } }, 108 })); 109 // Both problems are reported together; the order between them is not 110 // part of the contract. 111 await assert.rejects(withConfigEnv({}, () => loadWorkerConfig(file)), (e) => { 112 assert.match(e.message, /features\.bad\.mounts\[0\]/); 113 assert.match(e.message, /features\.bad\.nonsense/); 114 return true; 115 }); 116 await fs.rm(dir, { recursive: true, force: true }); 117 }); 118 119 test('shell quoting survives quotes and metacharacters', () => { 120 assert.equal(shellQuote('simple'), "'simple'"); 121 assert.equal(shellQuote("it's"), "'it'\\''s'"); 122 assert.equal(shellQuote('$(rm -rf /)'), "'$(rm -rf /)'"); 123 }); 124 125 test('a generated script echoes each command and stops on failure', () => { 126 const script = buildScript(['npm ci', "echo 'hi'"]); 127 assert.match(script, /^#!\/bin\/sh\nset -e\n/); 128 assert.ok(script.includes("printf '%s\\n' '$ npm ci'")); 129 assert.ok(script.includes('npm ci\n')); 130 // The echo of a command containing quotes stays a single argument. 131 assert.ok(script.includes("printf '%s\\n' '$ echo '\\''hi'\\'''")); 132 }); 133 134 test('container names are valid for docker and stay unique', () => { 135 const a = containerName('conductor', 'run1:package:arch=x86_64,pkg=musl'); 136 const b = containerName('conductor', 'run1:package:arch=x86_64,pkg=busybox'); 137 assert.match(a, /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/); 138 assert.notEqual(a, b); 139 // Distinct tasks that differ only past the truncation point still differ. 140 const long = 'x'.repeat(200); 141 assert.notEqual(containerName('c', `${long}a`), containerName('c', `${long}b`)); 142 }); 143 144 test('job arguments carry mounts, env, network and privilege', () => { 145 const runtime = createRuntime({ docker: 'docker', shell: 'sh' }); 146 const args = runtime.buildRunArgs({ 147 name: 'job1', 148 image: 'alpine:3', 149 network: 'net1', 150 workdir: '/work', 151 env: { FOO: 'bar' }, 152 mounts: ['/srv/k:/keys/k:ro'], 153 privileged: true, 154 entrypoint: 'sh', 155 command: ['/tmp/entrypoint.sh'], 156 }); 157 158 assert.equal(args[0], 'run'); 159 assert.ok(args.includes('--rm')); 160 assert.deepEqual(args.slice(args.indexOf('--network'), args.indexOf('--network') + 2), ['--network', 'net1']); 161 assert.ok(args.includes('--privileged')); 162 assert.ok(args.includes('/srv/k:/keys/k:ro')); 163 assert.deepEqual(args.slice(args.indexOf('--workdir'), args.indexOf('--workdir') + 2), ['--workdir', '/work']); 164 assert.ok(args.includes('FOO=bar')); 165 // The image must come before its command. 166 assert.ok(args.indexOf('alpine:3') < args.indexOf('/tmp/entrypoint.sh')); 167 }); 168 169 test('a task container is created without sharing anything with the worker', () => { 170 // The whole point of copying the tree in: no path of the worker's is 171 // handed to the daemon, so nothing has to line up between the two. 172 const runtime = createRuntime({ docker: 'docker', shell: 'sh' }); 173 const args = runtime.buildCreateArgs({ 174 name: 'job1', 175 image: 'alpine:3', 176 network: 'net1', 177 workdir: '/work', 178 env: { FOO: 'bar' }, 179 entrypoint: 'sh', 180 command: ['/tmp/entrypoint.sh'], 181 }); 182 183 assert.equal(args[0], 'create'); 184 assert.ok(!args.includes('--attach'), 'create takes no attach flags'); 185 assert.ok(!args.includes('--volume'), 'nothing of the worker is mounted in'); 186 assert.ok(!args.some((a) => a.includes(':/work')), 'the tree is copied in, never bind mounted'); 187 assert.deepEqual(args.slice(args.indexOf('--workdir'), args.indexOf('--workdir') + 2), ['--workdir', '/work']); 188 assert.ok(args.indexOf('alpine:3') < args.indexOf('/tmp/entrypoint.sh')); 189 }); 190 191 test('a feature still contributes its bind mounts', () => { 192 // Those name host paths an operator chose deliberately, which is a 193 // different thing from the worker sharing its own directories. 194 const runtime = createRuntime({ docker: 'docker', shell: 'sh' }); 195 const args = runtime.buildCreateArgs({ 196 name: 'job1', 197 image: 'alpine:3', 198 mounts: ['/srv/keys/build.rsa:/keys/build.rsa:ro'], 199 entrypoint: 'sh', 200 command: ['/tmp/entrypoint.sh'], 201 }); 202 203 assert.ok(args.includes('/srv/keys/build.rsa:/keys/build.rsa:ro')); 204 }); 205 206 test('features resolve into mounts, env and privilege', () => { 207 const available = { 208 dind: { mounts: [], env: {}, devices: [], privileged: true }, 209 'sign-key': { mounts: ['/srv/k:/keys/k:ro'], env: { KEY: '/keys/k' }, devices: [], privileged: false }, 210 }; 211 const resolved = resolveFeatures({ requires: ['sign-key', 'dind'] }, available); 212 assert.deepEqual(resolved.mounts, ['/srv/k:/keys/k:ro']); 213 assert.deepEqual(resolved.env, { KEY: '/keys/k' }); 214 assert.equal(resolved.privileged, true); 215 assert.deepEqual(resolved.missing, []); 216 }); 217 218 test('an unknown required feature is reported rather than ignored', () => { 219 const resolved = resolveFeatures({ requires: ['nope'] }, {}); 220 assert.deepEqual(resolved.missing, ['nope']); 221 }); 222 223 test('an artifact pattern reduces to the part that can be copied out', () => { 224 // A stopped container cannot expand a glob, so only the leading 225 // literal part of a pattern can be asked for by name. 226 assert.equal(patternPrefix('out/**'), 'out'); 227 assert.equal(patternPrefix('build/*.tar.gz'), 'build'); 228 assert.equal(patternPrefix('a/b/c/**'), 'a/b/c'); 229 assert.equal(patternPrefix('dist/app.tar'), 'dist/app.tar'); 230 231 // A pattern that could match anywhere has no usable prefix. 232 assert.equal(patternPrefix('**/*.log'), ''); 233 assert.equal(patternPrefix('*'), ''); 234 }); 235 236 test('overlapping artifact paths are copied out once', () => { 237 assert.deepEqual(copyOutPaths(['out/**', 'out/deep/**', 'build/*']), ['build', 'out']); 238 assert.deepEqual(copyOutPaths(['out/**', 'out/**']), ['out']); 239 240 // One pattern matching anywhere means the whole tree comes back, and 241 // there is no point copying anything else separately. 242 assert.deepEqual(copyOutPaths(['**/*.log', 'out/**']), ['']); 243 244 // A prefix must be a whole path segment: outer must not swallow out. 245 assert.deepEqual(copyOutPaths(['out/**', 'outer/**']), ['out', 'outer']); 246 }); 247 248 test('artifact collection globs, and refuses to leave the workspace', async () => { 249 const dir = await tempDir(); 250 const outside = await tempDir('conductor-outside-'); 251 await fs.writeFile(path.join(outside, 'secret.txt'), 'do not upload me'); 252 253 await fs.mkdir(path.join(dir, 'dist/nested'), { recursive: true }); 254 await fs.mkdir(path.join(dir, '.conductor'), { recursive: true }); 255 await fs.writeFile(path.join(dir, 'dist/app.bin'), 'binary'); 256 await fs.writeFile(path.join(dir, 'dist/nested/deep.bin'), 'deep'); 257 await fs.writeFile(path.join(dir, '.conductor/script.sh'), 'set -e'); 258 await fs.symlink(path.join(outside, 'secret.txt'), path.join(dir, 'dist/escape.txt')); 259 260 const found = await collectArtifacts(dir, ['dist/**'], { logger: { warn: () => {} } }); 261 const names = found.map((f) => f.path).sort(); 262 263 assert.ok(names.includes('dist/app.bin')); 264 assert.ok(names.includes('dist/nested/deep.bin')); 265 assert.ok(!names.includes('dist/escape.txt'), 'symlink out of the workspace must be refused'); 266 assert.ok(!names.some((n) => n.startsWith('.conductor'))); 267 assert.equal(found.find((f) => f.path === 'dist/app.bin').size, 6); 268 269 await fs.rm(dir, { recursive: true, force: true }); 270 await fs.rm(outside, { recursive: true, force: true }); 271 }); 272 273 test('artifact when controls collection', () => { 274 assert.equal(shouldCollect('on_success', true), true); 275 assert.equal(shouldCollect('on_success', false), false); 276 assert.equal(shouldCollect('on_failure', false), true); 277 assert.equal(shouldCollect('on_failure', true), false); 278 assert.equal(shouldCollect('always', false), true); 279 }); 280 281 test('an idle worker keeps running instead of exiting', async () => { 282 // A worker with nothing to do is waiting on a timer and nothing else. 283 // If that timer does not hold the event loop open, node decides it has 284 // finished and exits 0, which looks like a clean shutdown and is not. 285 const dir = await tempDir(); 286 const config = path.join(dir, 'worker.json'); 287 288 // Points at a port nothing is listening on, so every poll fails and the 289 // agent stays in its retry sleep. 290 await fs.writeFile(config, JSON.stringify({ 291 conductor_url: 'http://127.0.0.1:1', 292 token: 'irrelevant', 293 poll_interval: 1, 294 docker: 'true', 295 })); 296 297 const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js'); 298 const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' }); 299 300 const outcome = await new Promise((resolve) => { 301 const timer = setTimeout(() => resolve('still running'), 4000); 302 child.on('exit', (code) => { 303 clearTimeout(timer); 304 resolve(`exited with ${code}`); 305 }); 306 }); 307 308 child.kill('SIGKILL'); 309 assert.equal(outcome, 'still running'); 310 311 await fs.rm(dir, { recursive: true, force: true }); 312 }); 313 314 test('a worker stops promptly on a signal rather than after its poll interval', async () => { 315 const dir = await tempDir(); 316 const config = path.join(dir, 'worker.json'); 317 await fs.writeFile(config, JSON.stringify({ 318 conductor_url: 'http://127.0.0.1:1', 319 token: 'irrelevant', 320 // Long enough that waiting it out would fail this test. 321 poll_interval: 60, 322 docker: 'true', 323 })); 324 325 const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js'); 326 const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' }); 327 328 // Let it reach the sleep, then ask it to stop. 329 await new Promise((r) => { setTimeout(r, 1500); }); 330 const started = Date.now(); 331 child.kill('SIGTERM'); 332 333 const code = await new Promise((resolve) => { 334 const timer = setTimeout(() => resolve('timed out'), 10000); 335 child.on('exit', (c) => { clearTimeout(timer); resolve(c); }); 336 }); 337 338 child.kill('SIGKILL'); 339 assert.equal(code, 0, 'should exit cleanly on SIGTERM'); 340 assert.ok(Date.now() - started < 8000, 'should not wait out the poll interval'); 341 342 await fs.rm(dir, { recursive: true, force: true }); 343 }); 344 345 // A client stub that records what the log stream sends. 346 function recordingClient() { 347 const calls = []; 348 let size = 0; 349 return { 350 calls, 351 get text() { 352 return calls.map((c) => c.chunk.toString('utf8')).join(''); 353 }, 354 async appendLog(url, chunk, offset) { 355 calls.push({ chunk, offset }); 356 size = offset + chunk.length; 357 return { size }; 358 }, 359 }; 360 } 361 362 test('log output is batched and offsets advance', async () => { 363 const client = recordingClient(); 364 const log = createLogStream(client, 'http://x/log', { flushMs: 5 }); 365 366 log.write('hello '); 367 log.write('world\n'); 368 await log.close(); 369 370 assert.equal(client.text, 'hello world\n'); 371 assert.equal(client.calls[0].offset, 0); 372 assert.equal(log.offset, 12); 373 }); 374 375 test('a conflicting offset resynchronises instead of duplicating', async () => { 376 const calls = []; 377 let first = true; 378 const client = { 379 async appendLog(url, chunk, offset) { 380 calls.push({ text: chunk.toString('utf8'), offset }); 381 if (first) { 382 first = false; 383 // The conductor already has the first four bytes. 384 return { conflict: true, expected: 4 }; 385 } 386 return { size: offset + chunk.length }; 387 }, 388 }; 389 390 const log = createLogStream(client, 'http://x/log', { flushMs: 5 }); 391 log.write('abcdefgh'); 392 await log.close(); 393 394 assert.equal(calls.length, 2); 395 assert.equal(calls[1].offset, 4); 396 assert.equal(calls[1].text, 'efgh', 'only the bytes the conductor lacks should be resent'); 397 }); 398 399 test('secrets are masked before anything leaves the host', async () => { 400 const client = recordingClient(); 401 const log = createLogStream(client, 'http://x/log', { masked: ['hunter2'], flushMs: 5 }); 402 403 log.write('password is hunter2 ok\n'); 404 await log.close(); 405 406 assert.ok(!client.text.includes('hunter2')); 407 assert.ok(client.text.includes('[masked]')); 408 }); 409 410 test('a secret split across two writes is still masked', async () => { 411 const client = recordingClient(); 412 const log = createLogStream(client, 'http://x/log', { masked: ['supersecret'], flushMs: 5 }); 413 414 log.write('token=super'); 415 log.write('secret done\n'); 416 await log.close(); 417 418 assert.ok(!client.text.includes('supersecret'), `leaked: ${client.text}`); 419 assert.ok(client.text.includes('[masked]')); 420 }); 421 422 test('a secret named while the task runs is masked from then on', async () => { 423 const client = recordingClient(); 424 const log = createLogStream(client, 'http://x/log', { flushMs: 5 }); 425 426 assert.equal(log.addSecret('abcdef123456'), true); 427 log.write('token is abcdef123456 ok\n'); 428 await log.close(); 429 430 assert.ok(!client.text.includes('abcdef123456'), `leaked: ${client.text}`); 431 assert.ok(client.text.includes('[masked]')); 432 }); 433 434 test('naming a secret also masks output still buffered', async () => { 435 const client = recordingClient(); 436 const log = createLogStream(client, 'http://x/log', { flushMs: 50 }); 437 438 log.write('echoed abcdef123456 early\n'); 439 log.addSecret('abcdef123456'); 440 await log.close(); 441 442 assert.ok(!client.text.includes('abcdef123456'), `leaked: ${client.text}`); 443 }); 444 445 test('a value too short to mask is refused rather than made into noise', async () => { 446 const client = recordingClient(); 447 const log = createLogStream(client, 'http://x/log', { flushMs: 5 }); 448 449 assert.equal(log.addSecret('ab'), false); 450 log.write('ab is everywhere\n'); 451 await log.close(); 452 453 assert.equal(client.text, 'ab is everywhere\n'); 454 }); 455 456 test('a failing conductor does not break the task', async () => { 457 const client = { 458 async appendLog() { 459 throw new Error('conductor unreachable'); 460 }, 461 }; 462 const log = createLogStream(client, 'http://x/log', { flushMs: 5, logger: { warn: () => {} } }); 463 log.write('some output\n'); 464 await log.close(); 465 assert.match(log.error.message, /unreachable/); 466 }); 467 468 const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; 469 470 function setupTask(overrides = {}) { 471 return { 472 id: 'task-setup', 473 name: 'build', 474 image: 'busybox', 475 sha: 'a'.repeat(40), 476 attempt: 1, 477 script: 'true', 478 env: {}, 479 masked: [], 480 timeout: 600, 481 heartbeat_interval: 5, 482 workdir: '/workspace', 483 endpoints: { 484 log: 'http://x/log', 485 source: 'http://x/source.tar.gz', 486 heartbeat: 'http://x/heartbeat', 487 done: 'http://x/done', 488 artifact: 'http://x/artifacts', 489 }, 490 ...overrides, 491 }; 492 } 493 494 function exitingChild(code = 0) { 495 const child = new EventEmitter(); 496 child.stdout = new PassThrough(); 497 child.stderr = new PassThrough(); 498 setImmediate(() => { 499 child.stdout.end(); 500 child.stderr.end(); 501 child.emit('close', code); 502 }); 503 return child; 504 } 505 506 test('a heartbeat goes out while a task is still being set up', async () => { 507 const beats = []; 508 const client = { 509 async appendLog(url, chunk, offset) { return { size: offset + chunk.length }; }, 510 async heartbeat() { beats.push(Date.now()); return { cancelled: false }; }, 511 async source() { return Readable.from([gzipSync(Buffer.alloc(0))]); }, 512 async done() { return {}; }, 513 }; 514 515 // Stands in for an image pull that outlasts the conductor's reaper clock. 516 const runtime = { 517 async createNetwork() {}, 518 async removeNetwork() {}, 519 async create() { 520 const until = Date.now() + 8000; 521 while (Date.now() < until && beats.length === 0) { 522 await new Promise((r) => { setTimeout(r, 25); }); 523 } 524 }, 525 async copyStreamIn(name, stream) { 526 stream.resume(); 527 await new Promise((r) => { stream.on('end', r); stream.on('error', r); }); 528 }, 529 async copyIn() {}, 530 async copyOut() {}, 531 async kill() {}, 532 async remove() {}, 533 startCreated: () => ({ child: exitingChild(0), args: [] }), 534 }; 535 536 const result = await runTask({ 537 cfg: { name: 'w', shell: 'sh', default_timeout: 600, features: {} }, 538 client, 539 runtime, 540 task: setupTask(), 541 logger: quiet, 542 }); 543 544 assert.ok(beats.length > 0, 'the conductor heard nothing while the image was pulling'); 545 assert.equal(result.success, true); 546 }); 547 548 test('a task claimed as the worker stops is handed back, not started', async () => { 549 const events = []; 550 let task = null; 551 const server = createServer((req, res) => { 552 events.push(req.url); 553 res.setHeader('content-type', 'application/json'); 554 if (req.url === '/api/v1/tasks/claim') { 555 // Slow enough that the signal lands while the claim is in flight. 556 setTimeout(() => res.end(JSON.stringify({ ok: true, data: { task } })), 1200); 557 return; 558 } 559 res.end(JSON.stringify({ ok: true, data: {} })); 560 }); 561 562 await new Promise((r) => server.listen(0, '127.0.0.1', r)); 563 const port = server.address().port; 564 const at = (p) => `http://127.0.0.1:${port}${p}`; 565 task = setupTask({ 566 endpoints: { 567 log: at('/log'), 568 source: at('/source.tar.gz'), 569 heartbeat: at('/heartbeat'), 570 done: at('/done'), 571 artifact: at('/artifacts'), 572 }, 573 }); 574 575 const dir = await tempDir(); 576 const config = path.join(dir, 'worker.json'); 577 await fs.writeFile(config, JSON.stringify({ 578 conductor_url: `http://127.0.0.1:${port}`, 579 token: 'irrelevant', 580 poll_interval: 60, 581 docker: 'true', 582 })); 583 584 const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js'); 585 const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' }); 586 587 await new Promise((r) => { setTimeout(r, 700); }); 588 child.kill('SIGTERM'); 589 590 const code = await new Promise((resolve) => { 591 const timer = setTimeout(() => resolve('timed out'), 10000); 592 child.on('exit', (c) => { clearTimeout(timer); resolve(c); }); 593 }); 594 child.kill('SIGKILL'); 595 server.close(); 596 597 assert.equal(code, 0, 'should still exit cleanly'); 598 assert.ok(events.includes('/done'), `expected the task to be handed back, saw ${events.join(', ')}`); 599 assert.ok( 600 !events.includes('/source.tar.gz'), 601 `the task was started despite the signal, saw ${events.join(', ')}` 602 ); 603 604 await fs.rm(dir, { recursive: true, force: true }); 605 });