@alchemy.run/test 2.0.0-beta.85 → 2.0.0-beta.87

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 (69) hide show
  1. package/lib/Alchemy.d.ts +0 -1
  2. package/lib/Alchemy.d.ts.map +1 -1
  3. package/lib/Alchemy.js +7 -6
  4. package/lib/Alchemy.js.map +1 -1
  5. package/lib/Api.d.ts +2 -1
  6. package/lib/Api.d.ts.map +1 -1
  7. package/lib/Api.js.map +1 -1
  8. package/lib/Bun.d.ts +0 -1
  9. package/lib/Bun.d.ts.map +1 -1
  10. package/lib/Bun.js +0 -1
  11. package/lib/Bun.js.map +1 -1
  12. package/lib/Capture.d.ts.map +1 -1
  13. package/lib/Capture.js.map +1 -1
  14. package/lib/Core.d.ts +15 -1
  15. package/lib/Core.d.ts.map +1 -1
  16. package/lib/Core.js +17 -4
  17. package/lib/Core.js.map +1 -1
  18. package/lib/DevCli.d.ts.map +1 -1
  19. package/lib/DevCli.js.map +1 -1
  20. package/lib/Expect.d.ts.map +1 -1
  21. package/lib/Expect.js.map +1 -1
  22. package/lib/FileLog.d.ts.map +1 -1
  23. package/lib/FileLog.js.map +1 -1
  24. package/lib/Http.d.ts.map +1 -1
  25. package/lib/Http.js.map +1 -1
  26. package/lib/Model.d.ts +15 -2
  27. package/lib/Model.d.ts.map +1 -1
  28. package/lib/Model.js.map +1 -1
  29. package/lib/PlainReporter.d.ts +1 -2
  30. package/lib/PlainReporter.d.ts.map +1 -1
  31. package/lib/PlainReporter.js +19 -93
  32. package/lib/PlainReporter.js.map +1 -1
  33. package/lib/Plan.d.ts.map +1 -1
  34. package/lib/Plan.js.map +1 -1
  35. package/lib/Registry.d.ts.map +1 -1
  36. package/lib/Registry.js.map +1 -1
  37. package/lib/Reporter.d.ts.map +1 -1
  38. package/lib/Reporter.js.map +1 -1
  39. package/lib/Runner.d.ts.map +1 -1
  40. package/lib/Runner.js +66 -43
  41. package/lib/Runner.js.map +1 -1
  42. package/lib/StrayOutput.d.ts.map +1 -1
  43. package/lib/StrayOutput.js.map +1 -1
  44. package/lib/Tags.d.ts.map +1 -1
  45. package/lib/Tags.js.map +1 -1
  46. package/lib/TestState.d.ts.map +1 -1
  47. package/lib/TestState.js.map +1 -1
  48. package/lib/Tui.d.ts.map +1 -1
  49. package/lib/Tui.js.map +1 -1
  50. package/lib/Vitest.d.ts.map +1 -1
  51. package/lib/Vitest.js +0 -1
  52. package/lib/Vitest.js.map +1 -1
  53. package/lib/WorktreeStage.d.ts.map +1 -1
  54. package/lib/WorktreeStage.js.map +1 -1
  55. package/lib/cli.d.ts.map +1 -1
  56. package/lib/cli.js.map +1 -1
  57. package/lib/index.d.ts +1 -1
  58. package/lib/index.d.ts.map +1 -1
  59. package/lib/index.js.map +1 -1
  60. package/package.json +9 -9
  61. package/src/Alchemy.ts +9 -7
  62. package/src/Api.ts +2 -1
  63. package/src/Bun.ts +0 -2
  64. package/src/Core.ts +39 -5
  65. package/src/Model.ts +20 -2
  66. package/src/PlainReporter.ts +20 -108
  67. package/src/Runner.ts +62 -42
  68. package/src/Vitest.ts +0 -1
  69. package/src/index.ts +1 -0
@@ -4,8 +4,7 @@
4
4
  * Reports progress through the collection phase (importing a large suite's
5
5
  * files takes tens of seconds before any test can run), then logs one line per
6
6
  * test as it finishes, prefixed with a `[done/total]` counter (failures include
7
- * their error and captured output inline); if nothing finishes for 10s it
8
- * prints the list of currently-running tests; at the end every failure is
7
+ * their error and captured output inline); at the end every failure is
9
8
  * repeated consolidated.
10
9
  */
11
10
  import * as Effect from "effect/Effect";
@@ -53,25 +52,14 @@ const indent = (text: string, prefix = " "): string =>
53
52
  interface ReporterState {
54
53
  readonly hookLogs: Map<string, ReadonlyArray<LogEntry>>;
55
54
  readonly failedFiles: Set<string>;
56
- /**
57
- * Currently-executing work items: tests AND file-level hooks. Hooks must
58
- * be tracked too — a `beforeAll` deploy can legitimately run for many
59
- * minutes with no test starting or finishing, and if the stall report
60
- * only covered tests, such a run would print nothing at all and read as
61
- * deadlocked.
62
- */
63
- /** `queued`: started, but still waiting for its `exclusive` run lock. */
64
- readonly running: Map<string, { label: string; startedAt: number; queued?: boolean }>;
65
55
  /** Total tests in the run, known once RunStart arrives. */
66
56
  total: number;
67
57
  /** Tests that have reported a result so far, and how they went. */
68
58
  completed: number;
69
59
  passed: number;
70
60
  failed: number;
71
- lastEnd: number;
72
- /** Running-set fingerprint of the last stall report — dedupes repeats. */
73
- lastStallKey: string;
74
- stallTimer: ReturnType<typeof setInterval> | undefined;
61
+ /** Repaints the collection line while files are importing. */
62
+ collectTimer: ReturnType<typeof setInterval> | undefined;
75
63
  /** Files discovered for the collection phase. */
76
64
  collectTotal: number;
77
65
  collectDone: number;
@@ -128,6 +116,10 @@ const renderCollect = (state: ReporterState, force: boolean): void => {
128
116
  /** Settle the progress line on the final count so the total cost stays visible. */
129
117
  const finishCollect = (state: ReporterState): void => {
130
118
  state.collecting = false;
119
+ if (state.collectTimer !== undefined) {
120
+ clearInterval(state.collectTimer);
121
+ state.collectTimer = undefined;
122
+ }
131
123
  clearCollectLine(state);
132
124
  state.collectCurrent = "";
133
125
  renderCollect(state, true);
@@ -159,44 +151,6 @@ const progress = (state: ReporterState, status: TestResult["status"]): string =>
159
151
  ].join(" ");
160
152
  };
161
153
 
162
- const STALL_AFTER_MS = 10_000;
163
- const STALL_LIST_MAX = 25;
164
-
165
- /**
166
- * Print the currently-running tests when nothing has finished for a while.
167
- * Printed AT MOST ONCE per distinct running set: while the exact same tests
168
- * stay stuck, nothing is re-printed — the timestamp on the report tells the
169
- * reader how long ago it was taken.
170
- */
171
- const printStalled = (state: ReporterState): void => {
172
- if (state.running.size === 0) return;
173
- if (Date.now() - state.lastEnd < STALL_AFTER_MS) return;
174
- const key = [...state.running.entries()]
175
- .map(([id, item]) => `${id}${item.queued ? " (queued)" : ""}`)
176
- .sort()
177
- .join("\n");
178
- if (key === state.lastStallKey) return;
179
- state.lastStallKey = key;
180
- const now = Date.now();
181
- const timestamp = new Date(now).toTimeString().slice(0, 8);
182
- const items = [...state.running.values()].sort((a, b) => a.startedAt - b.startedAt);
183
- const queued = items.filter((item) => item.queued).length;
184
- const lines = [
185
- dim(
186
- `⧗ [${timestamp}] no tests finished in the last 10s — ${items.length - queued} still running` +
187
- (queued > 0 ? `, ${queued} queued for an exclusive lock:` : ":"),
188
- ),
189
- ];
190
- for (const item of items.slice(0, STALL_LIST_MAX)) {
191
- const elapsed = formatDuration(now - item.startedAt);
192
- lines.push(dim(` ${item.label} (${item.queued ? `queued ${elapsed}` : elapsed})`));
193
- }
194
- if (items.length > STALL_LIST_MAX) {
195
- lines.push(dim(` … ${items.length - STALL_LIST_MAX} more`));
196
- }
197
- writeDirect(`${lines.join("\n")}\n`);
198
- };
199
-
200
154
  const onEvent = (event: TestEvent, state: ReporterState): Effect.Effect<void> => {
201
155
  switch (event._tag) {
202
156
  case "PlanPreview":
@@ -209,6 +163,13 @@ const onEvent = (event: TestEvent, state: ReporterState): Effect.Effect<void> =>
209
163
  state.collectTotal = event.files.length;
210
164
  state.collectStartedAt = Date.now();
211
165
  state.collecting = true;
166
+ if (state.collectTimer === undefined) {
167
+ state.collectTimer = setInterval(() => {
168
+ if (state.collecting) renderCollect(state, false);
169
+ }, 1000);
170
+ // Don't hold the process open once the run's fibers finish.
171
+ (state.collectTimer as unknown as { unref?: () => void }).unref?.();
172
+ }
212
173
  return write(dim(`collecting ${event.files.length} test files...`));
213
174
  case "FileCollected":
214
175
  return Effect.sync(() => {
@@ -223,36 +184,7 @@ const onEvent = (event: TestEvent, state: ReporterState): Effect.Effect<void> =>
223
184
  write(dim(`running ${event.tests.length} tests from ${event.files} files\n`)),
224
185
  ),
225
186
  );
226
- case "TestStart":
227
- return Effect.sync(() => {
228
- state.running.set(event.test.id, {
229
- label: `${event.test.file} > ${event.test.titlePath.join(" > ")}`,
230
- startedAt: Date.now(),
231
- queued: true,
232
- });
233
- });
234
- case "TestLocked":
235
- return Effect.sync(() => {
236
- const item = state.running.get(event.test.id);
237
- if (item !== undefined) {
238
- state.running.set(event.test.id, { label: item.label, startedAt: Date.now() });
239
- }
240
- });
241
- case "HookStart":
242
- return Effect.sync(() => {
243
- state.running.set(`${event.file} :: ${event.hook}`, {
244
- label: `${event.file} > [${event.hook} hook]`,
245
- startedAt: Date.now(),
246
- });
247
- });
248
- case "HookEnd":
249
- return Effect.sync(() => {
250
- state.running.delete(`${event.file} :: ${event.hook}`);
251
- state.lastEnd = Date.now();
252
- });
253
187
  case "TestEnd": {
254
- state.running.delete(event.test.id);
255
- state.lastEnd = Date.now();
256
188
  const title = `${dim(event.test.file)} ${dim(">")} ${event.test.titlePath.join(` ${dim(">")} `)}`;
257
189
  const duration = dim(`(${formatDuration(event.result.durationMs)})`);
258
190
  const retries = event.result.retries > 0 ? yellow(` [retried x${event.result.retries}]`) : "";
@@ -306,6 +238,10 @@ const onEvent = (event: TestEvent, state: ReporterState): Effect.Effect<void> =>
306
238
  return Effect.void;
307
239
  }
308
240
  case "RunEnd":
241
+ if (state.collectTimer !== undefined) {
242
+ clearInterval(state.collectTimer);
243
+ state.collectTimer = undefined;
244
+ }
309
245
  return printSummary(event.summary, state.hookLogs);
310
246
  default:
311
247
  return Effect.void;
@@ -382,14 +318,11 @@ export const PlainReporterLive: Layer.Layer<Reporter> = Layer.sync(Reporter)(()
382
318
  const state: ReporterState = {
383
319
  hookLogs: new Map(),
384
320
  failedFiles: new Set(),
385
- running: new Map(),
386
321
  total: 0,
387
322
  completed: 0,
388
323
  passed: 0,
389
324
  failed: 0,
390
- lastEnd: Date.now(),
391
- lastStallKey: "",
392
- stallTimer: undefined,
325
+ collectTimer: undefined,
393
326
  collectTotal: 0,
394
327
  collectDone: 0,
395
328
  collectStartedAt: Date.now(),
@@ -398,29 +331,8 @@ export const PlainReporterLive: Layer.Layer<Reporter> = Layer.sync(Reporter)(()
398
331
  collectLinePending: false,
399
332
  collecting: false,
400
333
  };
401
- // Ticks through both phases: repaints the collection line while files are
402
- // importing, reports stalled tests once the run is executing.
403
- const tick = (): void => {
404
- if (state.collecting) renderCollect(state, false);
405
- else printStalled(state);
406
- };
407
334
  return {
408
- emit: (event) =>
409
- Effect.suspend(() => {
410
- if (
411
- (event._tag === "CollectStart" || event._tag === "RunStart") &&
412
- state.stallTimer === undefined
413
- ) {
414
- state.stallTimer = setInterval(tick, 1000);
415
- // Don't hold the process open once the run's fibers finish.
416
- (state.stallTimer as unknown as { unref?: () => void }).unref?.();
417
- }
418
- if (event._tag === "RunEnd" && state.stallTimer !== undefined) {
419
- clearInterval(state.stallTimer);
420
- state.stallTimer = undefined;
421
- }
422
- return onEvent(event, state);
423
- }),
335
+ emit: (event) => Effect.suspend(() => onEvent(event, state)),
424
336
  waitForExit: () => Effect.void,
425
337
  };
426
338
  });
package/src/Runner.ts CHANGED
@@ -24,7 +24,7 @@ import * as Path from "effect/Path";
24
24
  import * as Semaphore from "effect/Semaphore";
25
25
  import { makeCapture } from "./Capture.ts";
26
26
  import { makeFileLog, type FileLog } from "./FileLog.ts";
27
- import type { Exclusive, FileSuite, Hook, LogEntry, Suite, TestCase } from "./Model.ts";
27
+ import type { Exclusive, FileSuite, Hook, Limit, LogEntry, Suite, TestCase } from "./Model.ts";
28
28
  import { containsOnly, forEachTest, makeFileSuite, titlePath } from "./Model.ts";
29
29
  import type { TestPlan } from "./Plan.ts";
30
30
  import * as Registry from "./Registry.ts";
@@ -309,18 +309,35 @@ const EXCLUSIVE_PERMITS = 100_000;
309
309
 
310
310
  interface LockSpec {
311
311
  readonly global: boolean;
312
- /** Sorted, so holders of several labels acquire them in one order. */
313
- readonly labels: ReadonlyArray<string>;
312
+ /** Sorted by label, so holders of several labels acquire them in one order. */
313
+ readonly labels: ReadonlyArray<Limit>;
314
314
  }
315
315
 
316
- const lockSpec = (values: ReadonlyArray<Exclusive | undefined>): LockSpec => ({
317
- global: values.some((value) => value === true),
318
- labels: [
319
- ...new Set(
320
- values.filter((value): value is string => typeof value === "string" && value !== ""),
321
- ),
322
- ].sort(),
323
- });
316
+ const lockSpec = (values: ReadonlyArray<Exclusive | undefined>): LockSpec => {
317
+ const flat = values.flatMap((value) => (Array.isArray(value) ? value : [value])) as Array<
318
+ boolean | string | Limit | undefined
319
+ >;
320
+ // A plain label is a one-permit lock; the tightest permit count wins.
321
+ const labels = new Map<string, number>();
322
+ for (const value of flat) {
323
+ const limit =
324
+ typeof value === "string"
325
+ ? value === ""
326
+ ? undefined
327
+ : { label: value, permits: 1 }
328
+ : typeof value === "object" && value !== null
329
+ ? value
330
+ : undefined;
331
+ if (limit === undefined) continue;
332
+ labels.set(limit.label, Math.min(labels.get(limit.label) ?? Infinity, limit.permits));
333
+ }
334
+ return {
335
+ global: flat.some((value) => value === true),
336
+ labels: [...labels]
337
+ .sort(([a], [b]) => a.localeCompare(b))
338
+ .map(([label, permits]) => ({ label, permits })),
339
+ };
340
+ };
324
341
 
325
342
  interface RunLock {
326
343
  readonly withLock: (
@@ -342,10 +359,11 @@ interface RunLock {
342
359
  const makeRunLock = Effect.gen(function* () {
343
360
  const permits = yield* Semaphore.make(EXCLUSIVE_PERMITS);
344
361
  const labels = new Map<string, Semaphore.Semaphore>();
345
- const labelLock = (label: string): Semaphore.Semaphore => {
362
+ // A label's permit count is fixed by its first holder.
363
+ const labelLock = ({ label, permits }: Limit): Semaphore.Semaphore => {
346
364
  let lock = labels.get(label);
347
365
  if (lock === undefined) {
348
- lock = Semaphore.makeUnsafe(1);
366
+ lock = Semaphore.makeUnsafe(Math.max(1, Math.floor(permits)));
349
367
  labels.set(label, lock);
350
368
  }
351
369
  return lock;
@@ -702,40 +720,19 @@ const runTest = Effect.fn(function* (test: TestCase, ctx: ExecContext) {
702
720
  let exit = yield* runAttempt();
703
721
  while (attemptNeedsRetry(exit, test.fails) && retries < (test.retry ?? ctx.options.retry)) {
704
722
  retries++;
723
+ // The retry replaces this attempt's result; keep its error in the run
724
+ // log, or a test that passes on retry hides what failed (and leaked).
725
+ ctx.fileLog.appendTestLine(`${meta.file} > ${meta.titlePath.join(" > ")}`, {
726
+ level: "ERROR",
727
+ message: `attempt ${retries} failed, retrying: ${attemptOutcome(exit, test).error ?? "unknown"}`,
728
+ time: new Date(),
729
+ });
705
730
  // Clear IN PLACE — TestStart handed this array's reference out.
706
731
  ctx.capture.release(logs);
707
732
  exit = yield* runAttempt();
708
733
  }
709
734
 
710
- let status: TestResult["status"];
711
- let error: string | undefined;
712
- if (wasInterrupted(exit)) {
713
- status = "fail";
714
- error = "killed (interrupted by user)";
715
- } else if (Exit.isFailure(exit)) {
716
- status = "fail";
717
- error = prettyCause(exit.cause);
718
- } else {
719
- const failedHook = hookError(exit.value);
720
- const bodyExit = exit.value.body;
721
- if (failedHook !== undefined) {
722
- status = "fail";
723
- error = failedHook;
724
- } else if (test.fails && bodyExit !== undefined) {
725
- if (Exit.isFailure(bodyExit)) {
726
- status = "pass";
727
- } else {
728
- status = "fail";
729
- error = "expected test to fail, but it passed";
730
- }
731
- } else if (bodyExit !== undefined && Exit.isSuccess(bodyExit)) {
732
- status = "pass";
733
- } else {
734
- status = "fail";
735
- error = bodyExit === undefined ? "test body did not run" : prettyCause(bodyExit.cause);
736
- }
737
- }
738
-
735
+ const { status, error } = attemptOutcome(exit, test);
739
736
  if (ctx.interactive) ctx.completed.add(meta.id);
740
737
  const result: TestResult = { status, durationMs, error, logs, retries };
741
738
  ctx.results.set(meta.id, {
@@ -746,6 +743,29 @@ const runTest = Effect.fn(function* (test: TestCase, ctx: ExecContext) {
746
743
  if (!ctx.interactive && status !== "fail") ctx.capture.release(logs);
747
744
  });
748
745
 
746
+ /** The status and error a finished attempt reports. */
747
+ const attemptOutcome = (
748
+ exit: Exit.Exit<TestAttempt, unknown>,
749
+ test: TestCase,
750
+ ): { status: TestResult["status"]; error: string | undefined } => {
751
+ if (wasInterrupted(exit)) return { status: "fail", error: "killed (interrupted by user)" };
752
+ if (Exit.isFailure(exit)) return { status: "fail", error: prettyCause(exit.cause) };
753
+ const failedHook = hookError(exit.value);
754
+ const bodyExit = exit.value.body;
755
+ if (failedHook !== undefined) return { status: "fail", error: failedHook };
756
+ if (test.fails && bodyExit !== undefined) {
757
+ return Exit.isFailure(bodyExit)
758
+ ? { status: "pass", error: undefined }
759
+ : { status: "fail", error: "expected test to fail, but it passed" };
760
+ }
761
+ if (bodyExit !== undefined && Exit.isSuccess(bodyExit))
762
+ return { status: "pass", error: undefined };
763
+ return {
764
+ status: "fail",
765
+ error: bodyExit === undefined ? "test body did not run" : prettyCause(bodyExit.cause),
766
+ };
767
+ };
768
+
749
769
  /** Fail every (non-skipped) test in a subtree without running it. */
750
770
  const failSubtree = Effect.fn(function* (suite: Suite, ctx: ExecContext, error: string) {
751
771
  const tests: Array<TestCase> = [];
package/src/Vitest.ts CHANGED
@@ -1,4 +1,3 @@
1
- /** @effect-diagnostics anyUnknownInErrorContext:off */
2
1
  import { it } from "@effect/vitest";
3
2
  import type { AlchemyContext } from "alchemy/AlchemyContext";
4
3
  import type { CompiledStack, StackDefinition } from "alchemy/Stack";
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export type {
49
49
  Exclusive,
50
50
  FileSuite,
51
51
  Hook,
52
+ Limit,
52
53
  LogEntry,
53
54
  Mode,
54
55
  Suite,