@uniflowed/test 0.0.0-alpha.12 → 0.0.0-alpha.13

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/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  //
13
13
  // The whole surface is importable from here, so a test file has one import.
14
14
 
15
+ export type { Expect, Expectation, Matchers } from "./internal/expect.js";
15
16
  export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
16
17
  export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
17
18
  export type { Uft } from "./internal/namespace.js";
@@ -11,7 +11,61 @@
11
11
  // `.resolves` and `.rejects` settle the promise first and then apply the same
12
12
  // matcher table to what came out, so `await expect(p).resolves.toBe(1)` reads
13
13
  // the way the synchronous form does.
14
+ //
15
+ // # The names are written down, and the behaviour is not
16
+ //
17
+ // `expect` used to be `$FlowFixMe`, and so was everything it handed back. That
18
+ // is a hole in the published type of the package a project writes every one of
19
+ // its assertions against: `expect(user).toBaa(1)` was not a misspelling
20
+ // anybody's checker would find, `expect(list).toHaveLength("3")` was not a type
21
+ // error, and `expect(p).resolves` on a value that is not a promise was fine
22
+ // until it ran. Every test in this repository is written against `expect`,
23
+ // which is the largest surface in the packages and was the least checked.
24
+ //
25
+ // So [`Matchers`] below writes the names out, one signature per matcher, the
26
+ // way `@uniflowed/react-testing`'s `Queries` writes out its thirty-six. Flow
27
+ // has no template literal types and no way to read a name out of a value, so
28
+ // the listing has to exist for the type to exist at all. What is *not*
29
+ // repeated is any behaviour: [`verdicts`] is still the one place a matcher is
30
+ // decided, and the listing is a naming that a reader can check against it by
31
+ // eye.
32
+ //
33
+ // [`Matchers`] is generic in what a matcher *returns*, which is what lets
34
+ // `.resolves` reuse the one listing: the same forty-one names, each handing
35
+ // back a promise.
36
+ //
37
+ // # The received value's type is not carried, and that was tried
38
+ //
39
+ // ubugeeei-prod/uf#402 asked for a second parameter as well — the type handed
40
+ // to `expect`, carried through the matchers so that `expect(count).toBe("two")`
41
+ // is an error at the call. It is written here rather than left for somebody to
42
+ // discover, because it looks obviously right and is not.
43
+ //
44
+ // `toBe` is `Object.is`, and identity is not assignability. Typing it
45
+ // `(expected: T)` demands that the expected value be a subtype of the received
46
+ // one, which is a direction the runtime has no opinion about and which ordinary
47
+ // assertions fail in both:
48
+ // `expect(document.activeElement).toBe(screen.getByRole("button"))` compares an
49
+ // `HTMLElement | null` with an `Element`, neither is the other's subtype, and
50
+ // that is the most common assertion in a DOM test. Jest and Vitest both type
51
+ // this argument as `unknown` for the same reason.
52
+ //
53
+ // Worse, the parameter has to be *inferred*, and `expect(x)` is where a lot of
54
+ // otherwise unconstrained expressions sit. `await
55
+ // expect(client.request("/users/1")).resolves.toEqual({ id: 1 })` stops
56
+ // checking and starts reporting that `request`'s own type parameter is
57
+ // underconstrained, because a `mixed` parameter asked nothing of the argument
58
+ // and a generic one asks it to be solved. `expect([])` becomes "cannot
59
+ // determine type of empty array literal". Both are correct tests, and the type
60
+ // that rejects them is worse than the type that missed a mistyped comparison.
61
+ //
62
+ // So the received value arrives as `mixed`, and what makes a matcher checked is
63
+ // its own signature: `toHaveLength` wants a number, `toMatch` a pattern,
64
+ // `toBeTypeOf` one of the eight words `typeof` answers with, and every name is
65
+ // a name. The comparison between two unrelated types stays unchecked, and it is
66
+ // the only part of the issue that does.
14
67
 
68
+ import type { AsymmetricMatcher } from "./asymmetric.js";
15
69
  import type { SpyCall } from "./spy.js";
16
70
  import * as asymmetric from "./asymmetric.js";
17
71
  import * as snapshot from "./snapshot.js";
@@ -45,6 +99,152 @@ type Verdict = {|
45
99
  readonly received?: string,
46
100
  |};
47
101
 
102
+ /** What `typeof` can answer, for the matcher that compares against it. */
103
+ type TypeName =
104
+ | "bigint"
105
+ | "boolean"
106
+ | "function"
107
+ | "number"
108
+ | "object"
109
+ | "string"
110
+ | "symbol"
111
+ | "undefined";
112
+
113
+ /**
114
+ * Every matcher, each returning `R`.
115
+ *
116
+ * Generic in the return type because the surface exists twice and that is the
117
+ * only thing that differs: `expect(x)` raises where a matcher fails and hands
118
+ * back nothing, while `expect(p).resolves` settles first and so hands back a
119
+ * promise. Writing the forty-one names once and saying what changes is the
120
+ * whole reason for the parameter — the alternative was the same list twice,
121
+ * with `=> void` on one copy and `=> Promise<void>` on the other, and a reader
122
+ * left to diff them.
123
+ *
124
+ * Where an argument is `mixed` it is because the runtime genuinely takes
125
+ * anything there and the checker would be lying to say otherwise: `toEqual`
126
+ * accepts an asymmetric matcher standing in for a value at any depth, and
127
+ * `toHaveValue` compares whatever a control is holding. Where it is not —
128
+ * `toHaveLength` wants a number, `toMatch` a string or a pattern, `toBeTypeOf`
129
+ * one of the eight words `typeof` produces — the narrower type is what the
130
+ * implementation already assumes, and saying it out loud is the point of the
131
+ * exercise.
132
+ *
133
+ * `not` is the same list again because negation is the only thing it changes.
134
+ * `resolves` and `rejects` are deliberately not here: they belong to
135
+ * [`Expectation`], because `expect(p).resolves.not` exists and
136
+ * `expect(x).not.resolves` does not.
137
+ */
138
+ export type Matchers<R> = {
139
+ readonly toBe: (expected: mixed) => R,
140
+ readonly toEqual: (expected: mixed) => R,
141
+ readonly toStrictEqual: (expected: mixed) => R,
142
+ readonly toBeTruthy: () => R,
143
+ readonly toBeFalsy: () => R,
144
+ readonly toBeNull: () => R,
145
+ readonly toBeUndefined: () => R,
146
+ readonly toBeDefined: () => R,
147
+ readonly toBeNaN: () => R,
148
+ readonly toBeGreaterThan: (expected: number) => R,
149
+ readonly toBeGreaterThanOrEqual: (expected: number) => R,
150
+ readonly toBeLessThan: (expected: number) => R,
151
+ readonly toBeLessThanOrEqual: (expected: number) => R,
152
+ readonly toBeCloseTo: (expected: number, digits?: number) => R,
153
+ readonly toContain: (expected: mixed) => R,
154
+ readonly toContainEqual: (expected: mixed) => R,
155
+ readonly toHaveLength: (expected: number) => R,
156
+ readonly toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => R,
157
+ readonly toMatch: (expected: string | RegExp) => R,
158
+ readonly toMatchObject: (expected: mixed) => R,
159
+ readonly toBeInstanceOf: (expected: mixed) => R,
160
+ readonly toBeTypeOf: (expected: TypeName) => R,
161
+ readonly toSatisfy: (predicate: (value: mixed) => boolean) => R,
162
+ readonly toMatchSnapshot: (hint?: string) => R,
163
+ readonly toMatchInlineSnapshot: (expected?: string) => R,
164
+ readonly toThrow: (...rest: $ReadOnlyArray<mixed>) => R,
165
+ readonly toHaveBeenCalled: () => R,
166
+ readonly toHaveBeenCalledTimes: (count: number) => R,
167
+ readonly toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => R,
168
+ readonly toHaveBeenLastCalledWith: (...args: $ReadOnlyArray<mixed>) => R,
169
+ readonly toBeInTheDocument: () => R,
170
+ readonly toBeVisible: () => R,
171
+ readonly toBeDisabled: () => R,
172
+ readonly toBeEnabled: () => R,
173
+ readonly toBeChecked: () => R,
174
+ readonly toBeRequired: () => R,
175
+ readonly toHaveFocus: () => R,
176
+ readonly toHaveAttribute: (name: string, value?: mixed) => R,
177
+ readonly toHaveClass: (...names: $ReadOnlyArray<string>) => R,
178
+ readonly toHaveTextContent: (expected: string | RegExp) => R,
179
+ readonly toHaveValue: (expected: mixed) => R,
180
+ readonly not: Matchers<R>,
181
+ ...
182
+ };
183
+
184
+ /**
185
+ * What `expect(received)` hands back.
186
+ *
187
+ * The matchers, plus the two that settle a promise before applying them. An
188
+ * intersection rather than a copy of the list with two lines added, and
189
+ * rather than an object spread, because a spread of an object type drops the
190
+ * `readonly` off every property it carries over — Flow computes a fresh object
191
+ * from the spread and the fresh one is writable, which would publish forty-one
192
+ * assignable matchers.
193
+ *
194
+ * # What is still not checked
195
+ *
196
+ * `expect(5).resolves` types, and fails when it runs. Saying otherwise needs
197
+ * `expect` to have two call signatures — one for a promise handing back a
198
+ * shape with `resolves`, one for everything else handing back a shape without
199
+ * — and Flow then requires the single function behind them to satisfy both,
200
+ * which no single function does. The overload is written down here rather than
201
+ * attempted because "it did not type" is the kind of thing that gets tried
202
+ * twice.
203
+ */
204
+ export type Expectation = Matchers<void> & {
205
+ readonly resolves: Matchers<Promise<void>>,
206
+ readonly rejects: Matchers<Promise<void>>,
207
+ ...
208
+ };
209
+
210
+ /**
211
+ * `expect` itself: callable, and carrying the matchers that stand in for a
212
+ * value instead of being one.
213
+ *
214
+ * Inexact, and it has to be. The value is a function, every function has
215
+ * `name`, `length`, `call`, `apply` and `bind`, and an exact object type
216
+ * refuses one for exactly that reason. Inexactness costs nothing that matters
217
+ * here: Flow still reports a read of a property this type does not list, which
218
+ * is what makes `expect.anythign()` an error.
219
+ */
220
+ export type Expect = {
221
+ (received: mixed): Expectation,
222
+ // `flow/unclear-type` reads source text rather than an AST, and the shape it
223
+ // recognises as a property key rather than a type is a name at the start of
224
+ // a line or straight after `{`, `,` or `;`. `readonly any:` is neither, so
225
+ // the rule reports Jest's, Vitest's and Sinon's name for this matcher as an
226
+ // `any` type. The rule's own comment already lists `@uniflowed/test`'s
227
+ // `expect.any` among the false positives it exists to avoid; this is the one
228
+ // spelling it still cannot see past.
229
+ // uf-lint-disable-next-line flow/unclear-type
230
+ readonly any: (constructor: mixed) => AsymmetricMatcher,
231
+ readonly anything: () => AsymmetricMatcher,
232
+ readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
233
+ readonly arrayContaining: (expected: $ReadOnlyArray<mixed>) => AsymmetricMatcher,
234
+ readonly stringContaining: (substring: string) => AsymmetricMatcher,
235
+ readonly stringMatching: (pattern: string | RegExp) => AsymmetricMatcher,
236
+ readonly closeTo: (value: number, digits?: number) => AsymmetricMatcher,
237
+ readonly not: {
238
+ readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
239
+ readonly arrayContaining: (expected: $ReadOnlyArray<mixed>) => AsymmetricMatcher,
240
+ readonly stringContaining: (substring: string) => AsymmetricMatcher,
241
+ readonly stringMatching: (pattern: string | RegExp) => AsymmetricMatcher,
242
+ readonly closeTo: (value: number, digits?: number) => AsymmetricMatcher,
243
+ ...
244
+ },
245
+ ...
246
+ };
247
+
48
248
  function propertyAt(
49
249
  value: mixed,
50
250
  path: string,
@@ -99,14 +299,15 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
99
299
  * that wants a `number`, and `(...args: $ReadOnlyArray<empty>)` accepts every
100
300
  * entry and rejects the call.
101
301
  *
102
- * `mixed` with a cast at the call would move the same unsoundness one line
103
- * without checking anything, because the caller is `bind`, whose result is
104
- * `$FlowFixMe` and whose result's result is `expect`, also `$FlowFixMe`. The
105
- * type that makes any of this checked is a written-out matcher interface —
106
- * one signature per matcher, plus `.not`, `.resolves` and `.rejects` — which
107
- * is what `expect`'s own annotation is waiting for, and is
108
- * ubugeeei-prod/uf#402. Until that exists, a narrower type here would be
109
- * precision nobody can reach.
302
+ * That is unchanged, and it is now the last of it. Where a caller used to meet
303
+ * this indexer through an unbroken chain of `$FlowFixMe`, the published
304
+ * surface is [`Expectation`] and the disagreement the indexer papers over is
305
+ * written down there, one signature per name. What survives is a table reached
306
+ * by a computed key inside this module, between `verdicts` and `bind` — two
307
+ * functions in one file, neither of which a consumer can see — and narrowing
308
+ * it means writing `bind`'s forty-one wrappers out by hand to avoid the
309
+ * lookup. That is a second copy of the listing to keep in step with this one,
310
+ * which is a worse trade than the suppression it removes.
110
311
  */
111
312
  function verdicts(received: mixed): {
112
313
  // uf-lint-disable-next-line flow/unclear-type
@@ -466,14 +667,42 @@ function verdicts(received: mixed): {
466
667
  * Walks the ancestors, because `display: none` on a parent hides a child whose
467
668
  * own style says nothing. `hidden`, `aria-hidden` and a `details` that is not
468
669
  * open each hide their subtree too.
670
+ *
671
+ * # The one thing a closed `<details>` still shows
672
+ *
673
+ * Its `<summary>`. A closed disclosure renders exactly one child and hides the
674
+ * rest, so the rule is "everything under a closed `<details>` except its
675
+ * summary" — and saying that needs the child the walk arrived from, not only
676
+ * the ancestor it is standing on. Without it the rule was written as "unless
677
+ * the `<details>` is the element being asked about", which exempted the
678
+ * disclosure from its own rule and left the summary inside it invisible:
679
+ * `expect(screen.getByText("More")).toBeVisible()` failed for the one thing on
680
+ * the screen, while the reader was looking at it.
681
+ *
682
+ * # Why this is not the walk in `react-testing`
683
+ *
684
+ * `packages/react-testing/internal/queries.js` has one that looks like this
685
+ * and answers a different question. `exposed` asks whether the accessibility
686
+ * tree announces the element, so it ignores `opacity: 0` — a screen reader
687
+ * reads text at zero opacity, which is exactly why hiding text that way is a
688
+ * bug rather than a technique — and it takes `aria-hidden` as decisive. This
689
+ * one asks whether a reader would *see* it, so the two answers part company
690
+ * there on purpose. The `<details>` half is the half they agree on, and it is
691
+ * written the same way in both.
469
692
  */
470
693
  function isVisible(node: Element): boolean {
694
+ let child: $FlowFixMe = null;
471
695
  let current: $FlowFixMe = node;
472
696
  while (current != null && current.nodeType === 1) {
473
697
  if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
474
698
  return false;
475
699
  }
476
- if (current.tagName === "DETAILS" && !current.hasAttribute("open") && current !== node) {
700
+ if (
701
+ child != null &&
702
+ current.tagName === "DETAILS" &&
703
+ !current.hasAttribute("open") &&
704
+ child.tagName !== "SUMMARY"
705
+ ) {
477
706
  return false;
478
707
  }
479
708
  const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
@@ -485,6 +714,7 @@ function isVisible(node: Element): boolean {
485
714
  return false;
486
715
  }
487
716
  }
717
+ child = current;
488
718
  current = current.parentElement;
489
719
  }
490
720
  return true;
@@ -510,6 +740,17 @@ function isDisabled(node: Element): boolean {
510
740
  *
511
741
  * `negated` decides which message a failing verdict raises, which is all of
512
742
  * what `.not` is.
743
+ *
744
+ * # Why the object is built rather than written
745
+ *
746
+ * `.not` has to be reached lazily or building an expectation would build its
747
+ * negation, which would build *its* negation, forever. A lazily installed
748
+ * property is not something an object literal carries, so the value is
749
+ * completed with `Object.defineProperty` after it exists — and an object
750
+ * completed after the fact is not one Flow can check a literal against. That
751
+ * is what this `$FlowFixMe` is, and it now covers a construction rather than a
752
+ * published type: [`expectValue`] states the real one, and the checker holds
753
+ * every caller to it.
513
754
  */
514
755
  function bind(received: mixed, negated: boolean): $FlowFixMe {
515
756
  const table = verdicts(received);
@@ -591,8 +832,12 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
591
832
  * expect(() => parse("")).toThrow(/empty/);
592
833
  * await expect(load()).resolves.toHaveLength(3);
593
834
  * ```
835
+ *
836
+ * Takes a `mixed` and hands back a written-out [`Expectation`]. What each
837
+ * matcher will accept is decided by its own signature rather than by what was
838
+ * received, for the reasons this module's header sets out.
594
839
  */
595
- function expectValue(received: mixed): $FlowFixMe {
840
+ function expectValue(received: mixed): Expectation {
596
841
  const expectation: $FlowFixMe = bind(received, false);
597
842
  Object.defineProperty(expectation, "resolves", {
598
843
  get: () => settled(received, "resolve", false),
@@ -602,11 +847,45 @@ function expectValue(received: mixed): $FlowFixMe {
602
847
  }
603
848
 
604
849
  /**
605
- * Assert about a value.
850
+ * Build the callable that carries the asymmetric matchers.
606
851
  *
607
- * Declared with its matchers attached rather than assigned afterwards: a
608
- * shipped module may only declare, import and export at its top level, and
609
- * `expect.any = …` is a statement that runs when the module is imported.
852
+ * The statics are attached inside a builder rather than at the module's top
853
+ * level, the way `internal/registry.js` builds `describe`: a shipped module
854
+ * may only declare, import and export at its top level, and `expect.any = …`
855
+ * out here is a statement that runs when the module is imported.
856
+ *
857
+ * It was `Object.assign(expectValue, { … })`, which is what a reader expects
858
+ * and what does not type. Flow models `Object.assign` as returning the
859
+ * *target*, so the result of assigning matchers onto a function is still a
860
+ * function with no matchers on it — eight `prop-missing` errors saying so, and
861
+ * a `flow/unsafe-object-assign` suppression on top of them. Attaching to a
862
+ * local before it is returned is the same runtime value with none of that: the
863
+ * checker sees the statics arrive and holds the result to [`Expect`].
864
+ */
865
+ function expecting(): Expect {
866
+ const api = (received: mixed): Expectation => expectValue(received);
867
+ api.any = asymmetric.any;
868
+ api.anything = asymmetric.anything;
869
+ api.objectContaining = asymmetric.objectContaining;
870
+ api.arrayContaining = asymmetric.arrayContaining;
871
+ api.stringContaining = asymmetric.stringContaining;
872
+ api.stringMatching = asymmetric.stringMatching;
873
+ api.closeTo = asymmetric.closeTo;
874
+ api.not = {
875
+ objectContaining: (expected: interface {}) =>
876
+ asymmetric.not(asymmetric.objectContaining(expected)),
877
+ arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
878
+ asymmetric.not(asymmetric.arrayContaining(expected)),
879
+ stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
880
+ stringMatching: (pattern: string | RegExp) =>
881
+ asymmetric.not(asymmetric.stringMatching(pattern)),
882
+ closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
883
+ };
884
+ return api;
885
+ }
886
+
887
+ /**
888
+ * Assert about a value.
610
889
  *
611
890
  * The `expect.*` half are the matchers that stand in for a value instead of
612
891
  * being one. `expect(user).toEqual({ id: expect.any(String), name: "uf" })`
@@ -619,31 +898,4 @@ function expectValue(received: mixed): $FlowFixMe {
619
898
  * than a negated assertion around the whole object, and is the form a suite
620
899
  * being ported will already have.
621
900
  */
622
- // `flow/unsafe-object-assign` asks for an object spread, and a spread cannot
623
- // produce this value: `expect` is a *function* with matchers hanging off it,
624
- // and `{ ...expectValue, ...matchers }` is a plain object that a test cannot
625
- // call. `Object.assign` onto a callable is the only expression that makes one,
626
- // and the alternative the rule is really warning about — `expect.any = …`
627
- // afterwards — is the top-level statement the comment above rules out. What it
628
- // mutates is a function this module declared six lines up and exports here; no
629
- // object belonging to anybody else is touched.
630
- // uf-lint-disable-next-line flow/unsafe-object-assign
631
- export const expect: $FlowFixMe = Object.assign(expectValue, {
632
- any: asymmetric.any,
633
- anything: asymmetric.anything,
634
- objectContaining: asymmetric.objectContaining,
635
- arrayContaining: asymmetric.arrayContaining,
636
- stringContaining: asymmetric.stringContaining,
637
- stringMatching: asymmetric.stringMatching,
638
- closeTo: asymmetric.closeTo,
639
- not: {
640
- objectContaining: (expected: interface {}) =>
641
- asymmetric.not(asymmetric.objectContaining(expected)),
642
- arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
643
- asymmetric.not(asymmetric.arrayContaining(expected)),
644
- stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
645
- stringMatching: (pattern: string | RegExp) =>
646
- asymmetric.not(asymmetric.stringMatching(pattern)),
647
- closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
648
- },
649
- });
901
+ export const expect: Expect = expecting();
@@ -59,10 +59,15 @@ function environment(): { [string]: string } | null {
59
59
  }
60
60
 
61
61
  /**
62
- * Replace an environment variable for the rest of the test.
62
+ * Replace an environment variable for the rest of the file.
63
63
  *
64
- * Undone by `unstubAllEnvs`, which the runner calls between files — a stub that
65
- * outlived its test would be a test that passes alone and fails in a suite.
64
+ * The file, and not the test: `process.env` belongs to the process, so a stub
65
+ * stands until something puts it back. `./worker.js` does that between files,
66
+ * beside the spy registry it clears for the same reason — a worker serves many
67
+ * files, and a stub that outlived its file would be a test that passes because
68
+ * of another one, in a suite where which files share a worker is decided by a
69
+ * timings file. A case that wants a narrower scope calls `unstubAllEnvs` in an
70
+ * `afterEach`, which is also what makes the scope visible to a reader.
66
71
  */
67
72
  export function stubEnv(name: string, value: string | void): void {
68
73
  const env = environment();
@@ -97,7 +102,11 @@ export function unstubAllEnvs(): void {
97
102
  }
98
103
 
99
104
  /**
100
- * Replace a global for the rest of the test.
105
+ * Replace a global for the rest of the file.
106
+ *
107
+ * Undone by `unstubAllGlobals`, which `./worker.js` calls between files, for
108
+ * the reason [`stubEnv`] above gives: `globalThis` outlives every file that
109
+ * writes to it.
101
110
  *
102
111
  * Whether the global was the object's own property is recorded, because putting
103
112
  * back an inherited one by assignment would leave a copy that shadows whatever
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.12",
3
+ "version": "0.0.0-alpha.13",
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",
@@ -21,6 +21,6 @@
21
21
  "internal"
22
22
  ],
23
23
  "dependencies": {
24
- "@uniflowed/host": "0.0.0-alpha.12"
24
+ "@uniflowed/host": "0.0.0-alpha.13"
25
25
  }
26
26
  }
package/worker.js CHANGED
@@ -56,6 +56,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
56
56
  import { reset } from "./internal/registry.js";
57
57
  import { resetModuleState } from "./internal/modules.js";
58
58
  import { run } from "./internal/run.js";
59
+ import { unstubAllEnvs, unstubAllGlobals } from "./internal/namespace.js";
59
60
 
60
61
  /** What `uf` sends for one file. */
61
62
  type Request = {|
@@ -140,6 +141,16 @@ function write(event: { readonly [string]: mixed }): void {
140
141
  async function runFile(request: Request, generation: number): Promise<void> {
141
142
  const started = performance.now();
142
143
  reset();
144
+ // Every environment variable and global the previous file replaced goes back
145
+ // too. A spy lives in the registry `reset` clears, but a stub is a write to
146
+ // something the whole process shares: `uft.stubEnv("NODE_ENV", "production")`
147
+ // stays set for every later file this worker serves, and `uf test` fans files
148
+ // across workers by size, so which files those are changes with the timings
149
+ // file. That is a suite whose result depends on its schedule — and worse, one
150
+ // whose failure names the file that read the value rather than the file that
151
+ // wrote it. See ubugeeei-prod/uf#417.
152
+ unstubAllEnvs();
153
+ unstubAllGlobals();
143
154
  // Every module this file stood in for goes back, before the next file can
144
155
  // import one of them and be handed the previous file's stand-in. A worker
145
156
  // serves many files out of one module registry, so this is the difference