@elaraai/e3-api-tests 1.0.84 → 1.0.85

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.
Files changed (50) hide show
  1. package/README.md +13 -1
  2. package/dist/src/assertions.d.ts +15 -0
  3. package/dist/src/assertions.d.ts.map +1 -1
  4. package/dist/src/assertions.js +44 -1
  5. package/dist/src/assertions.js.map +1 -1
  6. package/dist/src/context.d.ts +29 -0
  7. package/dist/src/context.d.ts.map +1 -1
  8. package/dist/src/context.js +12 -9
  9. package/dist/src/context.js.map +1 -1
  10. package/dist/src/fixtures.d.ts +51 -4
  11. package/dist/src/fixtures.d.ts.map +1 -1
  12. package/dist/src/fixtures.js +78 -8
  13. package/dist/src/fixtures.js.map +1 -1
  14. package/dist/src/index.d.ts +12 -1
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +28 -14
  17. package/dist/src/index.js.map +1 -1
  18. package/dist/src/suites/cli.d.ts.map +1 -1
  19. package/dist/src/suites/cli.js +19 -1
  20. package/dist/src/suites/cli.js.map +1 -1
  21. package/dist/src/suites/dataflow.d.ts.map +1 -1
  22. package/dist/src/suites/dataflow.js +347 -74
  23. package/dist/src/suites/dataflow.js.map +1 -1
  24. package/dist/src/suites/dataset-pages.js +4 -4
  25. package/dist/src/suites/dataset-pages.js.map +1 -1
  26. package/dist/src/suites/dataset-transfer.d.ts.map +1 -1
  27. package/dist/src/suites/dataset-transfer.js +115 -14
  28. package/dist/src/suites/dataset-transfer.js.map +1 -1
  29. package/dist/src/suites/functions.d.ts.map +1 -1
  30. package/dist/src/suites/functions.js +77 -7
  31. package/dist/src/suites/functions.js.map +1 -1
  32. package/dist/src/suites/package-transfer.d.ts.map +1 -1
  33. package/dist/src/suites/package-transfer.js +31 -3
  34. package/dist/src/suites/package-transfer.js.map +1 -1
  35. package/dist/src/suites/platform.d.ts.map +1 -1
  36. package/dist/src/suites/platform.js +7 -5
  37. package/dist/src/suites/platform.js.map +1 -1
  38. package/dist/src/suites/record-deploy.d.ts.map +1 -1
  39. package/dist/src/suites/record-deploy.js +16 -9
  40. package/dist/src/suites/record-deploy.js.map +1 -1
  41. package/dist/src/suites/records-keyed.d.ts.map +1 -1
  42. package/dist/src/suites/records-keyed.js +3 -2
  43. package/dist/src/suites/records-keyed.js.map +1 -1
  44. package/dist/src/suites/records.d.ts.map +1 -1
  45. package/dist/src/suites/records.js +40 -3
  46. package/dist/src/suites/records.js.map +1 -1
  47. package/dist/src/suites/workspaces.d.ts.map +1 -1
  48. package/dist/src/suites/workspaces.js +65 -1
  49. package/dist/src/suites/workspaces.js.map +1 -1
  50. package/package.json +14 -10
@@ -5,16 +5,35 @@
5
5
  /**
6
6
  * Dataflow execution test suite.
7
7
  *
8
- * Tests: start, execute (blocking), poll for completion, logs
8
+ * Tests: start, execute (blocking), poll for completion, logs, and failures.
9
+ * A failing task fails in East, which every server runs: it is recorded
10
+ * `failed`, exit code 1, with its message in its stderr log. One case runs a
11
+ * command, a custom task's, which a server that runs no commands records
12
+ * `error` ({@link TestContext.commands}).
9
13
  */
10
14
  import { describe, it } from 'node:test';
11
15
  import assert from 'node:assert/strict';
12
16
  import { readFileSync } from 'node:fs';
13
- import { IntegerType, StringType, encodeBeast2For, decodeBeast2For, variant } from '@elaraai/east';
14
- import { packageImport, workspaceCreate, workspaceDeploy, workspaceStatus, dataflowExecuteLaunch, dataflowExecute, dataflowExecutePoll, dataflowCancel, dataflowGraph, datasetSet, datasetGet, taskLogs, taskExecutionList, ApiError, } from '@elaraai/e3-api-client';
15
- import { createPackageZip, createDiamondPackageZip, createFailingPackageZip, createSlowPackageZip, createParallelMixedPackageZip, createFailingDiamondPackageZip, createWideParallelPackageZip, createSlowDiamondPackageZip, } from '../fixtures.js';
16
- import { assertDataflowSucceeded } from '../assertions.js';
17
+ import { IntegerType, StringType, encodeBeast2For, decodeBeast2For, isValueOf, lessFor, variant } from '@elaraai/east';
18
+ import { TaskLogChunkType } from '@elaraai/e3-types';
19
+ import { packageImport, workspaceCreate, workspaceDeploy, workspaceStatus, dataflowExecuteLaunch, dataflowExecute, dataflowExecutePoll, dataflowEventsRemain, dataflowCancel, dataflowGraph, datasetSet, datasetGet, taskLogs, taskExecutionList, ApiError, } from '@elaraai/e3-api-client';
20
+ import { LOGGED_LINES, createPackageZip, createDiamondPackageZip, createFailingPackageZip, createLoggingPackageZip, createCommandPackageZip, createSlowPackageZip, createParallelMixedPackageZip, createFailingDiamondPackageZip, createWideParallelPackageZip, createSlowDiamondPackageZip, } from '../fixtures.js';
21
+ import { MALFORMED_WORKSPACE_NAMES, assertDataflowSucceeded, describeDataflowResult, refusedAsWorkspaceName } from '../assertions.js';
17
22
  import { waitFor } from '../cli.js';
23
+ /**
24
+ * Asserts a call is refused `bad_request`, with the message the server gives.
25
+ *
26
+ * @param call - The refused call
27
+ * @param message - The server's message, whole
28
+ */
29
+ async function rejectsBadRequest(call, message) {
30
+ await assert.rejects(call, (err) => {
31
+ assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
32
+ assert.strictEqual(err.code, 'bad_request');
33
+ assert.strictEqual(err.details, message);
34
+ return true;
35
+ });
36
+ }
18
37
  /** Helper: import package, create workspace, deploy */
19
38
  function withDeployed(setup, createZip, pkgName, wsName) {
20
39
  return async (t) => {
@@ -55,6 +74,7 @@ export function dataflowTests(setup) {
55
74
  const withSimpleExec = withDeployed(setup, createPackageZip, 'exec-pkg', 'exec-ws');
56
75
  const withDiamond = withDeployed(setup, createDiamondPackageZip, 'diamond-pkg', 'diamond-ws');
57
76
  const withFailing = withDeployed(setup, createFailingPackageZip, 'fail-pkg', 'fail-ws');
77
+ const withCommand = withDeployed(setup, createCommandPackageZip, 'cmd-pkg', 'cmd-ws');
58
78
  const withMixed = withDeployed(setup, createParallelMixedPackageZip, 'mixed-pkg', 'mixed-ws');
59
79
  const withFailingDiamond = withDeployed(setup, createFailingDiamondPackageZip, 'fdiamond-pkg', 'fdiamond-ws');
60
80
  const withWideParallel = async (t) => {
@@ -90,8 +110,9 @@ export function dataflowTests(setup) {
90
110
  const withNoExec = withDeployed(setup, createPackageZip, 'noexec-pkg', 'noexec-ws');
91
111
  const withCache = withDeployed(setup, createPackageZip, 'cache-pkg', 'cache-ws');
92
112
  const withFilter = withDeployed(setup, createDiamondPackageZip, 'filter-pkg', 'filter-ws');
113
+ const withForced = withDeployed(setup, createDiamondPackageZip, 'forced-pkg', 'forced-ws');
93
114
  const withGraph = withDeployed(setup, createDiamondPackageZip, 'graph-pkg', 'graph-ws');
94
- const withLogPag = withDeployed(setup, createPackageZip, 'logpag-pkg', 'logpag-ws');
115
+ const withLogPag = withDeployed(setup, createLoggingPackageZip, 'logpag-pkg', 'logpag-ws');
95
116
  const withEvtPag = withDeployed(setup, createDiamondPackageZip, 'evtpag-pkg', 'evtpag-ws');
96
117
  describe('dataflow', { concurrency: false }, () => {
97
118
  describe('simple execution', { concurrency: false }, () => {
@@ -115,21 +136,39 @@ export function dataflowTests(setup) {
115
136
  assert.ok(outputDataset, 'Output dataset .tasks.compute.output should exist');
116
137
  assert.strictEqual(outputDataset.status.type, 'up-to-date');
117
138
  });
118
- it('taskExecutionList lists the latest attempt per inputs by default and every attempt with all', async (t) => {
139
+ it('taskExecutionList lists a task\'s runs the latest first, a page at a time, each naming its execution', async (t) => {
140
+ const ctx = await withSimpleExec(t);
141
+ const opts = await ctx.opts();
142
+ const list = (page = {}) => taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'exec-ws', 'compute', opts, page);
143
+ // Three forced runs of the same inputs: three runs under one inputs hash.
144
+ for (let run = 0; run < 3; run++) {
145
+ assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'exec-ws', { force: true }, opts));
146
+ }
147
+ const every = await list();
148
+ assert.strictEqual(every.length, 3, 'every run is listed');
149
+ assert.ok(every.every(e => e.inputsHash === every[0].inputsHash && e.status.type === 'success'), 'all runs share the inputs hash');
150
+ const ids = every.map(e => e.executionId);
151
+ assert.strictEqual(new Set(ids).size, ids.length, 'each run names its own execution');
152
+ assert.deepStrictEqual(ids, [...ids].sort().reverse(), 'the latest first');
153
+ // A page at a time, each from before the last run of the page before
154
+ const first = await list({ limit: 2 });
155
+ assert.deepStrictEqual(first.map(e => e.executionId), ids.slice(0, 2));
156
+ const next = await list({ limit: 2, before: first[1].executionId });
157
+ assert.deepStrictEqual(next.map(e => e.executionId), ids.slice(2), 'the next page holds the runs before it');
158
+ assert.deepStrictEqual(await list({ before: ids[2] }), [], 'and the page after it is empty');
159
+ });
160
+ it('refuses a page of a task\'s runs whose limit is not a positive integer, or whose cursor is no execution\'s id', async (t) => {
119
161
  const ctx = await withSimpleExec(t);
120
162
  const opts = await ctx.opts();
121
- // Two forced runs of the same inputs: two attempts under one inputs hash.
122
- assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'exec-ws', { force: true }, opts));
123
- assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'exec-ws', { force: true }, opts));
124
- const latest = await taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'exec-ws', 'compute', opts);
125
- assert.strictEqual(latest.length, 1, 'one item per distinct inputs hash');
126
- assert.strictEqual(latest[0].status.type, 'success');
127
- const every = await taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'exec-ws', 'compute', opts, { all: true });
128
- assert.ok(every.length >= 2, `every attempt is listed, got ${every.length}`);
129
- assert.ok(every.every(e => e.inputsHash === latest[0].inputsHash), 'all attempts share the inputs hash');
130
- assert.strictEqual(new Set(every.map(e => e.startedAt)).size, every.length, 'attempts are distinct runs');
131
- const newest = every.map(e => e.startedAt).sort().at(-1);
132
- assert.strictEqual(latest[0].startedAt, newest, 'the default item is the newest attempt');
163
+ const list = (page) => taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'exec-ws', 'compute', opts, page);
164
+ await rejectsBadRequest(list({ limit: 0 }), 'limit must be a positive integer, got "0"');
165
+ await rejectsBadRequest(list({ limit: 1.5 }), 'limit must be a positive integer, got "1.5"');
166
+ await assert.rejects(list({ before: 'not-an-id' }), (err) => {
167
+ assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
168
+ assert.strictEqual(err.code, 'invalid_name');
169
+ assert.strictEqual(err.details?.kind, 'execution id');
170
+ return true;
171
+ });
133
172
  });
134
173
  it('dataflowExecuteLaunch triggers execution (non-blocking)', async (t) => {
135
174
  const ctx = await withSimpleExec(t);
@@ -185,12 +224,33 @@ export function dataflowTests(setup) {
185
224
  await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'exec-ws', { force: true }, opts);
186
225
  // Get logs
187
226
  const logs = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'exec-ws', 'compute', { stream: 'stdout' }, opts);
188
- // Logs should be returned (may be empty for simple tasks)
189
- assert.ok(typeof logs.data === 'string');
190
- assert.ok(typeof logs.offset === 'bigint');
191
- assert.ok(typeof logs.size === 'bigint');
192
- assert.ok(typeof logs.totalSize === 'bigint');
193
- assert.ok(typeof logs.complete === 'boolean');
227
+ // A chunk of its log, which may be empty for a simple task
228
+ assert.ok(isValueOf(logs, TaskLogChunkType), 'a TaskLogChunk');
229
+ });
230
+ it('taskLogs names the execution it reads, and whether it has ended, and reads the one a request names', async (t) => {
231
+ const ctx = await withLogPag(t);
232
+ const opts = await ctx.opts();
233
+ const read = (logOptions = {}) => taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'log', { stream: 'stdout', ...logOptions }, opts);
234
+ const latest = async () => (await taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'log', opts, { limit: 1 }))[0];
235
+ // A run's log names the run, which has ended
236
+ assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts));
237
+ const first = await latest();
238
+ const logs = await read();
239
+ assert.deepStrictEqual([logs.inputsHash, logs.executionId, logs.ended], [first.inputsHash, first.executionId, true]);
240
+ // Another run is the task's current execution, and a request naming
241
+ // the first still reads the first
242
+ assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts));
243
+ const second = await latest();
244
+ assert.notStrictEqual(second.executionId, first.executionId, 'the run is another execution');
245
+ assert.strictEqual((await read()).executionId, second.executionId);
246
+ const named = await read({ offset: 5, execution: { inputsHash: first.inputsHash, executionId: first.executionId } });
247
+ assert.deepStrictEqual([named.executionId, named.data], [first.executionId, LOGGED_LINES.slice(5)]);
248
+ // An execution the task does not record is none
249
+ await assert.rejects(read({ execution: { inputsHash: first.inputsHash, executionId: '0190a0b0-4444-7000-8000-000000000000' } }), (err) => {
250
+ assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
251
+ assert.strictEqual(err.code, 'execution_not_found');
252
+ return true;
253
+ });
194
254
  });
195
255
  });
196
256
  describe('diamond dependency execution', { concurrency: false }, () => {
@@ -234,15 +294,18 @@ export function dataflowTests(setup) {
234
294
  const opts = await ctx.opts();
235
295
  // Start execution
236
296
  await dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, 'diamond-ws', { force: true }, opts);
237
- // Poll and collect events
297
+ // Poll and collect events, each poll from the cursor the last gave
238
298
  const events = [];
299
+ let since = 0n;
239
300
  const maxWait = 60000;
240
301
  const startTime = Date.now();
241
302
  while (Date.now() - startTime < maxWait) {
242
- const state = await dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'diamond-ws', { offset: events.length }, opts);
303
+ const state = await dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'diamond-ws', { since }, opts);
243
304
  // Collect new events
244
305
  events.push(...state.events);
245
- if (state.status.type === 'completed' || state.status.type === 'failed') {
306
+ since = state.nextSeq;
307
+ // The run's end is its last word once no event of it is left
308
+ if ((state.status.type === 'completed' || state.status.type === 'failed') && !dataflowEventsRemain(state)) {
246
309
  break;
247
310
  }
248
311
  await new Promise(r => setTimeout(r, 100));
@@ -265,7 +328,8 @@ export function dataflowTests(setup) {
265
328
  assert.strictEqual(result.executed, 0n);
266
329
  assert.strictEqual(result.tasks.length, 1);
267
330
  assert.strictEqual(result.tasks[0].name, 'failing');
268
- assert.strictEqual(result.tasks[0].state.type, 'failed');
331
+ // The body's own failure: its runner records it, and exits 1
332
+ assert.deepStrictEqual(result.tasks[0].state, variant('failed', { exitCode: 1n }));
269
333
  });
270
334
  it('dataflowExecutePoll shows failed status after task failure', async (t) => {
271
335
  const ctx = await withFailing(t);
@@ -304,6 +368,33 @@ export function dataflowTests(setup) {
304
368
  assert.strictEqual(result2.failed, 1n);
305
369
  });
306
370
  });
371
+ // The suites' one command: a custom task's. Every other task is East,
372
+ // which every server runs; a server that runs no commands — e3 in a page —
373
+ // records the task error, saying so.
374
+ describe('command task', { concurrency: false }, () => {
375
+ it('records a failing command failed, or error on a server that runs no commands', async (t) => {
376
+ const ctx = await withCommand(t);
377
+ const opts = await ctx.opts();
378
+ const result = await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'cmd-ws', { force: true }, opts);
379
+ assert.strictEqual(result.success, false);
380
+ assert.strictEqual(result.failed, 1n);
381
+ assert.strictEqual(result.executed, 0n);
382
+ assert.strictEqual(result.tasks.length, 1);
383
+ const task = result.tasks[0];
384
+ assert.strictEqual(task.name, 'command');
385
+ if (ctx.commands) {
386
+ // The command ran, and exited 1
387
+ assert.deepStrictEqual(task.state, variant('failed', { exitCode: 1n }), describeDataflowResult(result));
388
+ }
389
+ else {
390
+ // Nothing ran, and the server says why
391
+ if (task.state.type !== 'error') {
392
+ assert.fail(`expected the command task recorded error on a server that runs no commands\n${describeDataflowResult(result)}`);
393
+ }
394
+ assert.match(task.state.value.message, /runs no commands/);
395
+ }
396
+ });
397
+ });
307
398
  describe('parallel task failures', { concurrency: false }, () => {
308
399
  describe('mixed success/failure', { concurrency: false }, () => {
309
400
  it('parallel tasks with mixed success/failure complete without stalling', async (t) => {
@@ -313,10 +404,10 @@ export function dataflowTests(setup) {
313
404
  // Dataflow should complete (not stall) and report failure
314
405
  assert.strictEqual(result.success, false);
315
406
  assert.strictEqual(result.failed, 1n);
316
- // The failing task must be reported
407
+ // The failing task must be reported: its body's failure, exit code 1
317
408
  const failC = result.tasks.find(t => t.name === 'fail_c');
318
409
  assert.ok(failC, 'fail_c task should be in results');
319
- assert.strictEqual(failC.state.type, 'failed');
410
+ assert.deepStrictEqual(failC.state, variant('failed', { exitCode: 1n }));
320
411
  // Tasks that did execute should have succeeded
321
412
  for (const task of result.tasks) {
322
413
  if (task.name !== 'fail_c') {
@@ -331,18 +422,24 @@ export function dataflowTests(setup) {
331
422
  await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'mixed-ws', { force: true }, opts);
332
423
  // taskLogs for failed task should NOT throw
333
424
  const logs = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'mixed-ws', 'fail_c', { stream: 'stderr' }, opts);
334
- assert.ok(typeof logs.data === 'string', 'logs.data should be a string');
335
- assert.ok(typeof logs.complete === 'boolean', 'logs.complete should be a boolean');
425
+ assert.ok(isValueOf(logs, TaskLogChunkType), 'a TaskLogChunk');
426
+ // Its stderr holds the failure's message, which its body made from
427
+ // its input as it ran
428
+ assert.match(logs.data, /fail_c fails, given 3/);
336
429
  });
337
430
  it('workspace status reflects failed tasks correctly', async (t) => {
338
431
  const ctx = await withMixed(t);
339
432
  const opts = await ctx.opts();
340
433
  await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'mixed-ws', { force: true }, opts);
341
434
  const status = await workspaceStatus(ctx.config.baseUrl, ctx.repoName, 'mixed-ws', opts);
342
- // Failed task should show 'failed' status, not stuck as 'in-progress'
435
+ // Failed task should show 'failed' status, not stuck as 'in-progress',
436
+ // with the exit code its runner gave its body's failure
343
437
  const failedTask = status.tasks.find(t => t.name === 'fail_c');
344
438
  assert.ok(failedTask, 'fail_c task should be in workspace status');
345
- assert.strictEqual(failedTask.status.type, 'failed', 'Failed task should have failed status');
439
+ if (failedTask.status.type !== 'failed') {
440
+ assert.fail(`Failed task should have failed status, got ${failedTask.status.type}`);
441
+ }
442
+ assert.strictEqual(failedTask.status.value.exitCode, 1n);
346
443
  // No task should be stuck as 'in-progress'
347
444
  for (const task of status.tasks) {
348
445
  assert.notStrictEqual(task.status.type, 'in-progress', `Task ${task.name} should not be stuck in-progress`);
@@ -365,7 +462,7 @@ export function dataflowTests(setup) {
365
462
  assert.ok(rightTask, 'right task should be in results');
366
463
  assert.ok(mergeTask, 'merge task should be in results');
367
464
  assert.strictEqual(leftTask.state.type, 'success');
368
- assert.strictEqual(rightTask.state.type, 'failed');
465
+ assert.deepStrictEqual(rightTask.state, variant('failed', { exitCode: 1n }));
369
466
  assert.strictEqual(mergeTask.state.type, 'skipped');
370
467
  });
371
468
  it('taskLogs returns execution_not_found for skipped task', async (t) => {
@@ -461,18 +558,47 @@ export function dataflowTests(setup) {
461
558
  }, 60000);
462
559
  assert.strictEqual(finalStatus, 'aborted');
463
560
  });
561
+ it('serves why a cancelled run\'s task stopped, in the task\'s history and its status', async (t) => {
562
+ const ctx = await withSlow(t);
563
+ const opts = await ctx.opts();
564
+ const history = () => taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'slow-ws', 'slow', opts);
565
+ await dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, 'slow-ws', { force: true }, opts);
566
+ // The task's attempt is recorded running before the cancel, so the
567
+ // cancel stops that attempt. Its log says it has not ended.
568
+ await waitFor(async () => (await history()).some((item) => item.status.type === 'running'), 60000);
569
+ const live = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'slow-ws', 'slow', { stream: 'stderr' }, opts);
570
+ assert.strictEqual(live.ended, false, 'the log of an execution still running has not ended');
571
+ await dataflowCancel(ctx.config.baseUrl, ctx.repoName, 'slow-ws', opts);
572
+ const cancelled = (items) => items.find((item) => item.status.type === 'cancelled');
573
+ await waitFor(async () => cancelled(await history()) !== undefined, 60000);
574
+ const stopped = cancelled(await history());
575
+ if (stopped?.reason.type !== 'some')
576
+ assert.fail('a cancelled attempt says why it stopped');
577
+ assert.strictEqual(stopped.reason.value.kind.type, 'aborted');
578
+ assert.notStrictEqual(stopped.reason.value.message, '', 'in words, as its log\'s last line does');
579
+ const ended = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'slow-ws', 'slow', {
580
+ stream: 'stderr', execution: { inputsHash: live.inputsHash, executionId: live.executionId },
581
+ }, opts);
582
+ assert.strictEqual(ended.ended, true, 'and once it is cancelled, it has');
583
+ // The task reads ready, naming why its latest attempt stopped.
584
+ const task = (await workspaceStatus(ctx.config.baseUrl, ctx.repoName, 'slow-ws', opts)).tasks.find((each) => each.name === 'slow');
585
+ assert.ok(task !== undefined, 'the task is in the status');
586
+ assert.strictEqual(task.status.type, 'ready');
587
+ if (task.stopped.type !== 'some')
588
+ assert.fail('the status names why the task\'s attempt stopped');
589
+ assert.strictEqual(task.stopped.value.kind.type, 'aborted');
590
+ });
464
591
  it('dataflowCancel returns error when no execution is running', async (t) => {
465
592
  const ctx = await withSlow(t);
466
593
  const opts = await ctx.opts();
467
- // Try to cancel when nothing is running
468
- try {
469
- await dataflowCancel(ctx.config.baseUrl, ctx.repoName, 'slow-ws', opts);
470
- assert.fail('Should have thrown an error');
471
- }
472
- catch (err) {
473
- assert.ok(err instanceof Error);
474
- // Expect error about no active execution
475
- }
594
+ // Nothing is running to cancel: the server says so, as the request
595
+ // meeting the workspace's state rather than a fault of its own
596
+ await assert.rejects(dataflowCancel(ctx.config.baseUrl, ctx.repoName, 'slow-ws', opts), (err) => {
597
+ assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
598
+ assert.strictEqual(err.code, 'dataflow_error');
599
+ assert.strictEqual(err.details?.message, 'No active execution for this workspace');
600
+ return true;
601
+ });
476
602
  });
477
603
  });
478
604
  // Concurrent set during execution - tests that datasetSet is not blocked by dataflowExecuteLaunch
@@ -627,6 +753,17 @@ export function dataflowTests(setup) {
627
753
  assert.ok(err.code === 'workspace_not_found' || err.code === 'workspace_not_deployed', `Expected workspace_not_found or workspace_not_deployed, got ${err.code}`);
628
754
  }
629
755
  });
756
+ it('dataflowExecuteLaunch refuses a name no workspace can have as invalid_name of a workspace, before it takes a lock', async (t) => {
757
+ const ctx = await setup(t);
758
+ const opts = await ctx.opts();
759
+ // Each is refused as a workspace's name, whatever orchestrator runs
760
+ // the repository's dataflows: `bad:name` not as a lock's, and
761
+ // `main#dataflow` without taking the lock a run of main's dataflow
762
+ // holds.
763
+ for (const [name, why] of MALFORMED_WORKSPACE_NAMES) {
764
+ await assert.rejects(dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, name, { force: true }, opts), refusedAsWorkspaceName(name, why));
765
+ }
766
+ });
630
767
  it('dataflowGraph returns error for non-existent workspace', async (t) => {
631
768
  const ctx = await setup(t);
632
769
  const opts = await ctx.opts();
@@ -701,6 +838,42 @@ export function dataflowTests(setup) {
701
838
  }
702
839
  });
703
840
  });
841
+ // A run forces every task, none, or the tasks it names. The diamond's left
842
+ // and right read the inputs, and merge reads both.
843
+ describe('forcing named tasks', { concurrency: false }, () => {
844
+ it('re-runs the tasks a run names, and serves the rest from the cache while their inputs hold', async (t) => {
845
+ const ctx = await withForced(t);
846
+ const opts = await ctx.opts();
847
+ assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'forced-ws', {}, opts), 'first run');
848
+ const result = await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'forced-ws', { force: ['left'] }, opts);
849
+ assertDataflowSucceeded(result, 'forced run');
850
+ // left runs again and writes what it wrote before, so merge, which
851
+ // reads it, is served from the cache as right is.
852
+ assert.deepStrictEqual(['left', 'right', 'merge'].map((name) => [name, result.tasks.find((task) => task.name === name)?.cached]), [['left', false], ['right', true], ['merge', true]], describeDataflowResult(result));
853
+ });
854
+ it('refuses a start forcing a task the graph lacks, or one the filter leaves out, before anything runs', async (t) => {
855
+ const ctx = await withForced(t);
856
+ const opts = await ctx.opts();
857
+ await assert.rejects(dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, 'forced-ws', { force: ['left', 'no_such_task'] }, opts), (err) => {
858
+ assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
859
+ assert.strictEqual(err.code, 'task_not_found');
860
+ assert.strictEqual(err.details?.task, 'no_such_task');
861
+ return true;
862
+ });
863
+ await assert.rejects(dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, 'forced-ws', { force: ['right'], filter: 'left' }, opts), (err) => {
864
+ assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
865
+ assert.strictEqual(err.code, 'dataflow_error');
866
+ assert.strictEqual(err.details?.message, "the run forces 'right', which the filter 'left' leaves out: a filtered run runs 'left' and the tasks it depends on, and no other");
867
+ return true;
868
+ });
869
+ // Neither started a run.
870
+ await assert.rejects(dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'forced-ws', {}, opts), (err) => {
871
+ assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
872
+ assert.strictEqual(err.code, 'execution_not_found');
873
+ return true;
874
+ });
875
+ });
876
+ });
704
877
  describe('dependency graph', { concurrency: false }, () => {
705
878
  it('dataflowGraph returns correct structure', async (t) => {
706
879
  const ctx = await withGraph(t);
@@ -727,43 +900,143 @@ export function dataflowTests(setup) {
727
900
  it('taskLogs supports offset and limit', async (t) => {
728
901
  const ctx = await withLogPag(t);
729
902
  const opts = await ctx.opts();
730
- // Execute to generate logs
731
- await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts);
732
- // Get full logs
733
- const full = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'compute', { stream: 'stdout' }, opts);
734
- // Skip test if log is too short for meaningful pagination
735
- if (full.totalSize < 5n) {
736
- return;
903
+ const read = (logOptions) => taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'log', { stream: 'stdout', ...logOptions }, opts);
904
+ const total = BigInt(LOGGED_LINES.length);
905
+ // The task writes three known lines to its stdout
906
+ assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts));
907
+ // The whole log
908
+ const full = await read({});
909
+ assert.strictEqual(full.data, LOGGED_LINES);
910
+ assert.strictEqual(full.offset, 0n);
911
+ assert.strictEqual(full.size, total);
912
+ assert.strictEqual(full.totalSize, total);
913
+ assert.strictEqual(full.complete, true);
914
+ // Its first 5 bytes: a limit cuts it, short of its end
915
+ const head = await read({ offset: 0, limit: 5 });
916
+ assert.strictEqual(head.data, 'line ');
917
+ assert.strictEqual(head.offset, 0n);
918
+ assert.strictEqual(head.size, 5n);
919
+ assert.strictEqual(head.totalSize, total);
920
+ assert.strictEqual(head.complete, false);
921
+ // A window from byte 5: an offset and a limit together
922
+ const middle = await read({ offset: 5, limit: 9 });
923
+ assert.strictEqual(middle.data, '0\nline 1\n');
924
+ assert.strictEqual(middle.offset, 5n);
925
+ assert.strictEqual(middle.size, 9n);
926
+ assert.strictEqual(middle.complete, false);
927
+ // From byte 14 to its end: an offset alone
928
+ const tail = await read({ offset: 14 });
929
+ assert.strictEqual(tail.data, 'line 2\n');
930
+ assert.strictEqual(tail.offset, 14n);
931
+ assert.strictEqual(tail.size, 7n);
932
+ assert.strictEqual(tail.complete, true);
933
+ // A window of no bytes is a window: it reports the log's size, which
934
+ // is how e3's CLI learns it
935
+ const probe = await read({ offset: 0, limit: 0 });
936
+ assert.strictEqual(probe.data, '');
937
+ assert.strictEqual(probe.size, 0n);
938
+ assert.strictEqual(probe.totalSize, total);
939
+ });
940
+ it('refuses a malformed window or stream with bad_request, before it reads a log', async (t) => {
941
+ const ctx = await withLogPag(t);
942
+ const opts = await ctx.opts();
943
+ assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts));
944
+ // Through the client, which sends a number as it prints
945
+ const windows = [
946
+ [{ offset: NaN }, 'offset must be a non-negative integer, got "NaN"'],
947
+ [{ offset: -1 }, 'offset must be a non-negative integer, got "-1"'],
948
+ [{ offset: 1.5 }, 'offset must be a non-negative integer, got "1.5"'],
949
+ [{ limit: NaN }, 'limit must be a non-negative integer, got "NaN"'],
950
+ [{ limit: -1 }, 'limit must be a non-negative integer, got "-1"'],
951
+ ];
952
+ for (const [window, message] of windows) {
953
+ await rejectsBadRequest(taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'log', { stream: 'stdout', ...window }, opts), message);
954
+ }
955
+ // As a request spells them: a word; a number past those a number holds
956
+ // exactly; a stream other than the two a log has, a way out of the
957
+ // execution among them; and a window for a task there is no log of,
958
+ // refused before the server looks for one
959
+ const logs = `${ctx.config.baseUrl}/api/repos/${encodeURIComponent(ctx.repoName)}/workspaces/logpag-ws/dataflow/logs`;
960
+ const headers = opts.token ? { 'Authorization': `Bearer ${opts.token}` } : {};
961
+ const requests = [
962
+ ['log?offset=abc', 'offset must be a non-negative integer, got "abc"'],
963
+ ['log?limit=abc', 'limit must be a non-negative integer, got "abc"'],
964
+ ['log?offset=99999999999999999999', 'offset must be at most 9007199254740991, got "99999999999999999999"'],
965
+ ['log?stream=stdin', 'stream must be stdout or stderr, got "stdin"'],
966
+ [`log?stream=${encodeURIComponent('../../stdout')}`, 'stream must be stdout or stderr, got "../../stdout"'],
967
+ ['no_such_task?offset=-1', 'offset must be a non-negative integer, got "-1"'],
968
+ [`log?inputs=${'a'.repeat(64)}`, 'inputs and execution name an execution together: give both, or neither'],
969
+ ['log?execution=0190a0b0-4444-7000-8000-000000000000', 'inputs and execution name an execution together: give both, or neither'],
970
+ ];
971
+ for (const [request, message] of requests) {
972
+ const response = await ctx.fetch(`${logs}/${request}`, { headers });
973
+ assert.strictEqual(response.status, 400, request);
974
+ assert.deepStrictEqual(await response.json(), { error: { type: 'bad_request', message } }, request);
737
975
  }
738
- // Get first 5 bytes
739
- const chunk1 = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'compute', { stream: 'stdout', offset: 0, limit: 5 }, opts);
740
- assert.strictEqual(chunk1.data, full.data.slice(0, 5));
741
- assert.strictEqual(chunk1.offset, 0n);
742
- // Get from byte 5 onwards
743
- const chunk2 = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'compute', { stream: 'stdout', offset: 5 }, opts);
744
- assert.strictEqual(chunk2.offset, 5n);
745
- assert.strictEqual(chunk2.data, full.data.slice(5));
746
976
  });
747
977
  });
748
978
  describe('event pagination', { concurrency: false }, () => {
749
- it('dataflowExecutePoll supports event offset and limit', async (t) => {
979
+ it('dataflowExecutePoll serves the events past its cursor, at most its limit, and the cursor past them', async (t) => {
750
980
  const ctx = await withEvtPag(t);
751
981
  const opts = await ctx.opts();
982
+ const poll = (stateOptions) => dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', stateOptions, opts);
752
983
  // Execute and wait for completion
753
984
  await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { force: true }, opts);
754
- // Get first event only
755
- const page1 = await dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { offset: 0, limit: 1 }, opts);
756
- assert.strictEqual(page1.events.length, 1, 'Should return exactly 1 event');
757
- assert.ok(page1.totalEvents >= 3n, `Expected at least 3 total events, got ${page1.totalEvents}`);
758
- // Get second event
759
- const page2 = await dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { offset: 1, limit: 1 }, opts);
760
- assert.strictEqual(page2.events.length, 1, 'Should return exactly 1 event');
761
- // Events should be different
762
- const event1 = page1.events[0];
763
- const event2 = page2.events[0];
764
- const event1Key = `${event1.type}:${'value' in event1 ? event1.value.task : ''}`;
765
- const event2Key = `${event2.type}:${'value' in event2 ? event2.value.task : ''}`;
766
- assert.notStrictEqual(event1Key, event2Key, 'Paginated events should be different');
985
+ const before = lessFor(IntegerType);
986
+ // Every event, and the cursor past the last
987
+ const all = await poll({});
988
+ assert.ok(all.events.length >= 3, `Expected at least 3 events, got ${all.events.length}`);
989
+ assert.ok(before(0n, all.nextSeq), 'the cursor moved past the events');
990
+ // A page of one, and the next from its cursor: the events in order
991
+ const page1 = await poll({ limit: 1 });
992
+ const page2 = await poll({ since: page1.nextSeq, limit: 1 });
993
+ assert.deepStrictEqual([...page1.events, ...page2.events], all.events.slice(0, 2), 'the first two events, in order');
994
+ assert.ok(before(page1.nextSeq, page2.nextSeq), 'each cursor past the events served');
995
+ // A poll that has every event is served none, and its cursor stays
996
+ const caughtUp = await poll({ since: all.nextSeq });
997
+ assert.deepStrictEqual([caughtUp.events, caughtUp.nextSeq], [[], all.nextSeq]);
998
+ assert.strictEqual(caughtUp.status.type, 'completed', 'with the run\'s state');
999
+ // Where the run's events end: a poll of every event reaches its last,
1000
+ // and a page of one leaves events for the next
1001
+ assert.strictEqual(all.lastSeq, all.nextSeq, 'a poll of every event is at the run\'s last');
1002
+ assert.deepStrictEqual([dataflowEventsRemain(all), dataflowEventsRemain(page1), dataflowEventsRemain(caughtUp)], [false, true, false]);
1003
+ // Each poll names the run, and another run is another id
1004
+ assert.deepStrictEqual([page1.runId, page2.runId, caughtUp.runId], [all.runId, all.runId, all.runId]);
1005
+ assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { force: true }, opts));
1006
+ assert.notStrictEqual((await poll({ limit: 0 })).runId, all.runId, 'the next run is another');
1007
+ });
1008
+ it('refuses a malformed cursor or limit with bad_request; a poll of no events carries the run\'s state', async (t) => {
1009
+ const ctx = await withEvtPag(t);
1010
+ const opts = await ctx.opts();
1011
+ assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { force: true }, opts));
1012
+ // Through the client, which sends a limit as it prints
1013
+ const windows = [
1014
+ [{ limit: NaN }, 'limit must be a non-negative integer, got "NaN"'],
1015
+ [{ limit: -1 }, 'limit must be a non-negative integer, got "-1"'],
1016
+ [{ limit: 1.5 }, 'limit must be a non-negative integer, got "1.5"'],
1017
+ [{ since: -1n }, 'since must be a non-negative integer, got "-1"'],
1018
+ ];
1019
+ for (const [window, message] of windows) {
1020
+ await rejectsBadRequest(dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', window, opts), message);
1021
+ }
1022
+ // As a request spells them: a word, and a number past those a number
1023
+ // holds exactly
1024
+ const execution = `${ctx.config.baseUrl}/api/repos/${encodeURIComponent(ctx.repoName)}/workspaces/evtpag-ws/dataflow/execution`;
1025
+ const headers = opts.token ? { 'Authorization': `Bearer ${opts.token}` } : {};
1026
+ const requests = [
1027
+ ['since=abc', 'since must be a non-negative integer, got "abc"'],
1028
+ ['since=1.5', 'since must be a non-negative integer, got "1.5"'],
1029
+ ['since=99999999999999999999', 'since must be at most 9007199254740991, got "99999999999999999999"'],
1030
+ ];
1031
+ for (const [query, message] of requests) {
1032
+ const response = await ctx.fetch(`${execution}?${query}`, { headers });
1033
+ assert.strictEqual(response.status, 400, query);
1034
+ assert.deepStrictEqual(await response.json(), { error: { type: 'bad_request', message } }, query);
1035
+ }
1036
+ const none = await dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { limit: 0 }, opts);
1037
+ assert.strictEqual(none.status.type, 'completed');
1038
+ assert.deepStrictEqual([none.events, none.nextSeq], [[], 0n], 'no events, and the cursor where it was');
1039
+ assert.ok(dataflowEventsRemain(none), 'and where the run\'s events end, past the cursor');
767
1040
  });
768
1041
  });
769
1042
  });