@uniflowed/test 0.0.0-alpha.34 → 0.0.0-alpha.35

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.
package/internal/axe.js CHANGED
@@ -207,6 +207,87 @@ function excerpt(html: string): string {
207
207
  return line.length > MAX_NODE_CHARS ? `${line.slice(0, MAX_NODE_CHARS)}…` : line;
208
208
  }
209
209
 
210
+ /**
211
+ * The audit this process started last, settled or not.
212
+ *
213
+ * axe-core runs one audit at a time per process and refuses a second outright
214
+ * ("Axe is already running") rather than queueing it. One file never starts two
215
+ * on purpose, but a worker serves many files: a case that timed out in the
216
+ * middle of an audit leaves that audit running, and the next file's first audit
217
+ * used to fail on it — reported under a file that had done nothing wrong.
218
+ */
219
+ let running: Promise<mixed> = Promise.resolve();
220
+
221
+ /**
222
+ * Run `audit` once the audit already running has settled, however it settles.
223
+ *
224
+ * Waiting rather than failing is safe because the audit being waited on always
225
+ * ends — axe-core walks a finite tree — and a case that waits too long is still
226
+ * bounded by its own timeout.
227
+ */
228
+ function afterTheAuditRunning<T>(audit: () => Promise<T>): Promise<T> {
229
+ const turn = running.then(audit, audit);
230
+ running = turn.then(
231
+ () => undefined,
232
+ () => undefined,
233
+ );
234
+ return turn;
235
+ }
236
+
237
+ /** How long to wait between looks at an audit this module did not start. */
238
+ const FOREIGN_AUDIT_POLL_MS = 10;
239
+
240
+ /** How long an audit this module did not start may hold axe-core. */
241
+ const FOREIGN_AUDIT_LIMIT_MS = 10000;
242
+
243
+ // The timer and the clock as they were when this module loaded, so a file that
244
+ // fakes timers cannot stall a wait for a real audit to end.
245
+ const pause: typeof setTimeout = setTimeout;
246
+ const now: () => number = Date.now.bind(Date);
247
+
248
+ /**
249
+ * Whether axe-core refused to start because an audit is already running.
250
+ *
251
+ * `running` queues every audit this module starts, so a refusal means an audit
252
+ * started somewhere else. `uf dev`'s overlay runs axe-core itself, and stopping
253
+ * the overlay cannot recall an audit already walking the page: its own test can
254
+ * leave one finishing into the next file in the worker.
255
+ */
256
+ function isAlreadyRunning(error: mixed): boolean {
257
+ return error instanceof Error && error.message.startsWith("Axe is already running");
258
+ }
259
+
260
+ /**
261
+ * Run the audit once axe-core is free of an audit this module did not start.
262
+ *
263
+ * Looks again every `FOREIGN_AUDIT_POLL_MS` until axe-core takes it, and after
264
+ * `FOREIGN_AUDIT_LIMIT_MS` says what held the engine rather than waiting on an
265
+ * audit that is not going to end.
266
+ */
267
+ function runOnceAxeIsFree(
268
+ axe: $FlowFixMe,
269
+ node: mixed,
270
+ options: mixed,
271
+ since: number = now(),
272
+ ): Promise<mixed> {
273
+ return Promise.resolve()
274
+ .then(() => axe.run(node, options))
275
+ .catch((error: mixed) => {
276
+ if (!isAlreadyRunning(error)) {
277
+ throw error;
278
+ }
279
+ if (now() - since >= FOREIGN_AUDIT_LIMIT_MS) {
280
+ throw new Error(
281
+ "axe-core has been running an audit uf did not start for ten seconds, such as " +
282
+ "`uf dev`'s overlay or a direct `axe.run`; this assertion could not start its own.",
283
+ );
284
+ }
285
+ return new Promise((resolve) => {
286
+ pause(resolve, FOREIGN_AUDIT_POLL_MS);
287
+ }).then(() => runOnceAxeIsFree(axe, node, options, since));
288
+ });
289
+ }
290
+
210
291
  /**
211
292
  * Audit `node` and report what it found, weakest results already dropped.
212
293
  *
@@ -229,7 +310,9 @@ export async function auditElement(
229
310
  }
230
311
  const axe = await axeEngine();
231
312
  const options = resolveOptions(overrides);
232
- const results = await axe.run(node, axeRunOptions(options));
313
+ const results = await afterTheAuditRunning(() =>
314
+ runOnceAxeIsFree(axe, node, axeRunOptions(options)),
315
+ );
233
316
  const found: Array<AxeViolation> = [];
234
317
  for (const raw of arrayAt(results, "violations")) {
235
318
  const violation: AxeViolation = {
@@ -8,10 +8,10 @@
8
8
  // the process, outlives the file, and is handed to whichever file the schedule
9
9
  // puts next in that worker.
10
10
  //
11
- // That second list is what this module is. It has been discovered three times,
12
- // once per entry, and each time the same way — a suite that passed alone and
13
- // failed beside another, naming the file that read the value rather than the
14
- // file that wrote it:
11
+ // That second list is what this module is. It has been discovered four times,
12
+ // and each time the same way — a suite that passed alone and failed beside
13
+ // another, naming the file that read the value rather than the file that wrote
14
+ // it:
15
15
  //
16
16
  // * ubugeeei-prod/uf#417, `uft.stubEnv("NODE_ENV", …)` still set for the
17
17
  // next file;
@@ -20,7 +20,11 @@
20
20
  // fired and the file hung with nothing on screen;
21
21
  // * ubugeeei-prod/uf#607, `document.body` still holding the markup a
22
22
  // hydration test wrote into it, so the next file's "there is one image on
23
- // the page" found six.
23
+ // the page" found six;
24
+ // * ubugeeei-prod/uf#944, the window around that body: a `matchMedia` a file
25
+ // removed, and the document's `FormData` left in place of Node's. That one is not on the list below, because this package does not
26
+ // make the window — `@uniflowed/react-testing` does, and registers how to
27
+ // put it back (see `registeredElsewhere`).
24
28
  //
25
29
  // Which files share a worker is decided by `.uf/test-timings.json`, so a leak
26
30
  // makes the *result* of a suite depend on how the machine was loaded the last
@@ -168,6 +172,37 @@ function restoreDocument(): void {
168
172
  }
169
173
  }
170
174
 
175
+ /**
176
+ * Where other packages register process-wide state of their own.
177
+ *
178
+ * A package this one does not depend on can still install something a file
179
+ * changes and the next file reads: `@uniflowed/react-testing` installs a window
180
+ * on the first render and keeps it for the process. Only that package knows
181
+ * what the window looked like when it made it, so it registers how to put it
182
+ * back — a name for the message below, and a function — in a `Map` under this
183
+ * symbol, and every entry runs after the list above.
184
+ *
185
+ * A symbol from the global registry rather than an export, so the package that
186
+ * registers needs no import of this one: under another runner the entry is
187
+ * never read and costs nothing.
188
+ */
189
+ const SHARED_STATE: symbol = Symbol.for("@uniflowed/test/shared-state");
190
+
191
+ /** Every entry another package registered, as this module's own entries are shaped. */
192
+ function registeredElsewhere(): $ReadOnlyArray<Shared> {
193
+ const registry: mixed = Reflect.get(globalThis, SHARED_STATE);
194
+ if (!(registry instanceof Map)) {
195
+ return [];
196
+ }
197
+ const entries: Array<Shared> = [];
198
+ for (const [what, restore] of registry) {
199
+ if (typeof what === "string" && typeof restore === "function") {
200
+ entries.push({ what, restore: () => void restore() });
201
+ }
202
+ }
203
+ return entries;
204
+ }
205
+
171
206
  /**
172
207
  * Put back everything the file that just ran may have changed.
173
208
  *
@@ -185,7 +220,7 @@ function restoreDocument(): void {
185
220
  */
186
221
  export function restoreSharedState(): void {
187
222
  let failure: { readonly what: string, readonly thrown: mixed } | null = null;
188
- for (const shared of SHARED) {
223
+ for (const shared of [...SHARED, ...registeredElsewhere()]) {
189
224
  try {
190
225
  shared.restore();
191
226
  } catch (thrown) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.34",
3
+ "version": "0.0.0-alpha.35",
4
4
  "description": "The test API and worker for `uf test`: describe/it, a full matcher set, and the process uf fans test files out to.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@
26
26
  "!*.test.js"
27
27
  ],
28
28
  "dependencies": {
29
- "@uniflowed/host": "0.0.0-alpha.34"
29
+ "@uniflowed/host": "0.0.0-alpha.35"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "axe-core": ">=4"