char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somgenerate test typedefs from typescript impleae10ac

main
11.0 KiB323 linesraw
1import {
2  ArrayPrototypeJoin,
3  ArrayPrototypePush,
4  ArrayPrototypeSplice,
5  Error,
6  ErrorGetStack,
7  ObjectCreate,
8  Promise,
9  String,
10  TypeError,
11  uncurryThis,
12} from "ext:core/primordials";
13import { op_test_config, op_test_event } from "ext:core/ops";
14import type { TestEvent } from "ext:core/ops";
15import type { TestContext } from "./main.js";
16import { clearTimeout, setTimeout } from "ext:timers/timers.js";
17import { required, usv } from "ext:web/idl.js";
18import { flag, optionsOf, registry, type Definition, type ParsedStep } from "./shared.js";
19
20type Report = (event: TestEvent) => void;
21interface Shared {
22  settled: boolean;
23  test: string;
24}
25interface Local {
26  running: boolean;
27  pending: Promise<boolean>[];
28  failed: number;
29}
30const indexOf = uncurryThis(String.prototype.indexOf);
31const { now: dateNow } = Date;
32
33function parseStep(nameOrOptions: unknown, fn: unknown, count: number): ParsedStep {
34  required(count, 1);
35  if (typeof nameOrOptions === "string") {
36    if (count > 2 || (count === 2 && typeof fn !== "function")) {
37      throw new TypeError("Expected step(name, fn)");
38    }
39    // The string form cannot express ignore, so a missing function is always
40    // an error here rather than a late internal TypeError.
41    if (fn === undefined) throw new TypeError(`Step "${usv(nameOrOptions)}" requires a function`);
42    return { name: usv(nameOrOptions), fn, ignore: false } as ParsedStep;
43  }
44  if (count > 1) throw new TypeError("Expected step(options)");
45  const options = optionsOf(nameOrOptions, ["name", "fn", "ignore"], "step");
46  if (options.name === undefined) throw new TypeError("Step requires a name");
47  const ignore = flag(options, "ignore", "Step");
48  const func = options.fn;
49  if (func === undefined) {
50    if (!ignore) throw new TypeError(`Step "${usv(options.name)}" requires a function`);
51  } else if (typeof func !== "function") {
52    throw new TypeError(`Step "${usv(options.name)}" requires a function`);
53  }
54  return { name: usv(options.name), fn: func, ignore } as ParsedStep;
55}
56
57function formatError(error: unknown): string {
58  let text;
59  try {
60    if (error instanceof Error) {
61      const stack = ErrorGetStack(error);
62      text = typeof stack === "string" && stack !== "" ? stack : String(error);
63    } else {
64      text = String(error);
65    }
66  } catch {
67    return "Unknown error";
68  }
69  // The runner's own frames never explain a failure.
70  const lines = text.split("\n");
71  const kept = [lines[0]];
72  for (let index = 1; index < lines.length; index++) {
73    if (
74      indexOf(lines[index], "ext:test/runner.js") === -1 &&
75      indexOf(lines[index], "ext:test/main.js") === -1 &&
76      indexOf(lines[index], "ext:test/shared.js") === -1
77    ) {
78      ArrayPrototypePush(kept, lines[index]);
79    }
80  }
81  return ArrayPrototypeJoin(kept, "\n");
82}
83
84function withTimeout(work: () => Promise<void>, ms: number, name: string): Promise<void> {
85  let timer: number | undefined;
86  const started = (() => {
87    try {
88      return { failed: false, value: work() };
89    } catch (error) {
90      return { failed: true, error };
91    }
92  })();
93  const attempt = started.failed
94    ? Promise.reject(started.error)
95    : Promise.resolve(started.value);
96  if (!(ms < Infinity)) return attempt;
97  const expired = new Promise<never>((_, reject) => {
98    timer = setTimeout(
99      () => reject(new Error(`Test "${name}" timed out after ${ms}ms`)),
100      ms,
101    );
102  });
103  return Promise.race([attempt, expired]).then(
104    (value) => {
105      clearTimeout(timer);
106      return value;
107    },
108    (error) => {
109      clearTimeout(timer);
110      throw error;
111    },
112  );
113}
114
115// One test's contexts: `shared` observes the whole test (its name for
116// stale-step errors and the settled flag), while each `local` tracks only
117// its own running step, pending children, and failed children — so a
118// concurrent violation fails its caller and ancestors, never its victim.
119function context(name: string, depth: number, report: Report, shared: Shared, local: Local): TestContext {
120  const self: { name: string; step: TestContext["step"] } = ObjectCreate(null);
121  self.name = name;
122  self.step = function step(nameOrOptions: unknown, fn?: unknown): Promise<boolean> {
123    required(arguments.length, 1);
124    const parsed = parseStep(nameOrOptions, fn, arguments.length);
125    if (shared.settled) {
126      throw new TypeError(`Step "${parsed.name}" called after "${shared.test}" settled`);
127    }
128    if (local.running) {
129      local.failed += 1;
130      report({
131        kind: "fail",
132        name: parsed.name,
133        depth: depth + 1,
134        elapsedMs: 0,
135        text: `Error: Started step "${parsed.name}" while another step was running`,
136      });
137      return Promise.resolve(false);
138    }
139    local.running = true;
140    const outcome = runStep(local, parsed, depth + 1, report, shared);
141    ArrayPrototypePush(local.pending, outcome);
142    return outcome;
143  };
144  return self;
145}
146
147// Waits out every step a body started without awaiting: an unawaited step
148// still decides its parent. Loops so a step spawned from a step callback
149// joins the same drain; the test timeout bounds evasive patterns.
150async function drainSteps(local: Local): Promise<void> {
151  let waited = 0;
152  while (waited < local.pending.length) {
153    waited = local.pending.length;
154    await Promise.all(local.pending);
155  }
156}
157
158function failedStepsText(failed: number): string {
159  return `due to ${failed} failed step${failed === 1 ? "" : "s"}`;
160}
161
162// A step never throws its body's failure: it reports and resolves false,
163// while the failure still fails every ancestor up to the test.
164async function runStep(owner: Local, parsed: ParsedStep, depth: number, report: Report, shared: Shared): Promise<boolean> {
165  const local: Local = { running: false, pending: [], failed: 0 };
166  const start = dateNow();
167  try {
168    if (parsed.ignore) {
169      report({ kind: "ignore", name: parsed.name, depth });
170      return false;
171    }
172    await parsed.fn(context(parsed.name, depth, report, shared, local));
173    await drainSteps(local);
174    // A completion after the test settled (a timed-out body waking up) has
175    // no parent left to report under, so it is dropped.
176    if (shared.settled) return false;
177    if (local.failed > 0) throw new Error(failedStepsText(local.failed));
178    report({ kind: "pass", name: parsed.name, depth, elapsedMs: dateNow() - start });
179    return true;
180  } catch (error) {
181    if (shared.settled) return false;
182    report({
183      kind: "fail",
184      name: parsed.name,
185      depth,
186      elapsedMs: dateNow() - start,
187      text: formatError(error),
188    });
189    owner.failed += 1;
190    return false;
191  } finally {
192    owner.running = false;
193  }
194}
195
196async function runTest(definition: Definition, report: Report): Promise<void> {
197  if (definition.ignore) {
198    report({ kind: "ignore", name: definition.name, depth: 0 });
199    return;
200  }
201  const shared = { settled: false, test: definition.name };
202  const local: Local = { running: false, pending: [], failed: 0 };
203  const start = dateNow();
204  try {
205    await withTimeout(async () => {
206      await definition.fn(context(definition.name, 0, report, shared, local));
207      await drainSteps(local);
208      if (local.failed > 0) throw new Error(failedStepsText(local.failed));
209    }, definition.timeout, definition.name);
210    report({
211      kind: "pass",
212      name: definition.name,
213      depth: 0,
214      elapsedMs: dateNow() - start,
215    });
216  } catch (error) {
217    report({
218      kind: "fail",
219      name: definition.name,
220      depth: 0,
221      elapsedMs: dateNow() - start,
222      text: formatError(error),
223    });
224  } finally {
225    shared.settled = true;
226  }
227}
228
229export async function runTests(): Promise<void> {
230  registry.sealed = true;
231  const config = op_test_config();
232  const filter = config.filter ?? null;
233  const matches = (name: string): boolean =>
234    filter === null || indexOf(name, filter) !== -1;
235  const selected: Definition[] = [];
236  for (let index = 0; index < registry.definitions.length; index++) {
237    if (matches(registry.definitions[index].name)) {
238      ArrayPrototypePush(selected, registry.definitions[index]);
239    } else {
240      op_test_event({ kind: "filtered", name: registry.definitions[index].name, depth: 0 });
241    }
242  }
243  if (config.noRun) {
244    for (let index = 0; index < selected.length; index++) {
245      const definition = selected[index];
246      op_test_event({
247        kind: definition.ignore ? "ignore" : "found",
248        name: definition.name,
249        depth: 0,
250      });
251    }
252    op_test_event({ kind: "done" });
253    return;
254  }
255  let focused = false;
256  for (let index = 0; index < selected.length; index++) {
257    if (selected[index].only) focused = true;
258  }
259  // Rejections nobody handles would otherwise abort the file through the
260  // host with no attribution, or vanish once the run resolves. Collect them
261  // under the running test and fail the file with the full list instead.
262  const strays: { promise: Promise<unknown>; reason: unknown; test: string | null }[] = [];
263  let current: string | null = null;
264  function onUnhandled(event: PromiseRejectionEvent): void {
265    try {
266      event.preventDefault();
267    } catch {
268      // The host decides; the list below still records what was lost.
269    }
270    ArrayPrototypePush(
271      strays,
272      { promise: event.promise, reason: event.reason, test: current },
273    );
274  }
275  function onHandled(event: PromiseRejectionEvent): void {
276    for (let index = 0; index < strays.length; index++) {
277      if (strays[index].promise === event.promise) {
278        ArrayPrototypeSplice(strays, index, 1);
279        break;
280      }
281    }
282  }
283  globalThis.addEventListener("unhandledrejection", onUnhandled);
284  globalThis.addEventListener("rejectionhandled", onHandled);
285  try {
286    for (let index = 0; index < selected.length; index++) {
287      const definition = selected[index];
288      if (focused && !definition.only) {
289        op_test_event({ kind: "filtered", name: definition.name, depth: 0 });
290        continue;
291      }
292      op_test_event({
293        kind: "start",
294        name: definition.name,
295        depth: 0,
296        timeoutMs: definition.timeout === Infinity ? null : definition.timeout,
297      });
298      current = definition.name;
299      await runTest(definition, op_test_event);
300      current = null;
301    }
302    op_test_event({ kind: "done" });
303    // Let strays queued during the run surface before judging them.
304    await new Promise<void>((resolve) => setTimeout(resolve, 0));
305    await new Promise<void>((resolve) => setTimeout(resolve, 0));
306    if (strays.length > 0) {
307      const lines = ["Unhandled rejections during the test run:"];
308      for (let index = 0; index < strays.length; index++) {
309        const entry = strays[index];
310        const during = entry.test === null ? "after the run" : `during "${entry.test}"`;
311        ArrayPrototypePush(lines, `  ${during}:`);
312        const text = formatError(entry.reason).split("\n");
313        for (let line = 0; line < text.length; line++) {
314          ArrayPrototypePush(lines, `    ${text[line]}`);
315        }
316      }
317      throw new Error(ArrayPrototypeJoin(lines, "\n"));
318    }
319  } finally {
320    globalThis.removeEventListener("unhandledrejection", onUnhandled);
321    globalThis.removeEventListener("rejectionhandled", onHandled);
322  }
323}