diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/js.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/js.ts index cb0cd792..e3576144 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/js.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/js.ts @@ -55,4 +55,191 @@ export const CHAINABLE_ITERATION_METHODS = new Set(["map", "filter", "forEach", // detection in `js-combine-iterations` filters that receiver out. export const ITERATOR_PRODUCING_METHOD_NAMES = new Set(["values", "keys", "entries"]); -export const TEST_FILE_PATTERN = /\.(?:test|spec|stories)\.[tj]sx?$/; +// Vitest browser mode / Storybook test-runner / Playwright Component +// Testing conventionally put the React under-test in a `*.browser.tsx` +// (or `*.browser.jsx`) module. These files *render* the component and +// drive ordered interactions exactly like a `.test.tsx`, so +// `async-parallel` would false-positive on the canonical +// render/expect/click/expect rhythm. +export const BROWSER_TEST_FILE_PATTERN = /\.browser\.[cm]?[jt]sx?$/; + +// Module identifiers whose presence in a file's imports proves the +// file is a test, story, or interaction-driving harness. Used by +// `async-parallel` to suppress the rule on files that aren't covered +// by the shared `isTestFilePath` path heuristic (e.g. a helper at +// `src/test-utils.ts` that imports `@testing-library/react`, or a +// Vitest browser fixture co-located with production code). +export const TEST_LIBRARY_IMPORT_SOURCES: ReadonlySet = new Set([ + "vitest", + "jest", + "mocha", + "chai", + "sinon", + "expect", + "ava", + "uvu", + "node:test", + "bun:test", + "@testing-library/react", + "@testing-library/react-native", + "@testing-library/react-hooks", + "@testing-library/dom", + "@testing-library/user-event", + "@testing-library/jest-dom", + "@testing-library/vue", + "@testing-library/svelte", + "@testing-library/preact", + "@testing-library/cypress", + "playwright", + "playwright-core", + "@playwright/test", + "@playwright/experimental-ct-react", + "@playwright/experimental-ct-react17", + "cypress", + "@cypress/react", + "@cypress/react18", + "@storybook/test", + "@storybook/test-runner", + "@storybook/testing-library", + "@storybook/jest", + "puppeteer", + "puppeteer-core", + "webdriverio", + "@wdio/globals", + "@nuxt/test-utils", +]); + +// Source-prefix matches catch sub-paths and scoped extensions that the +// `TEST_LIBRARY_IMPORT_SOURCES` set can't enumerate exhaustively +// (`vitest/browser`, `@vitest/spy`, `@playwright/test/reporter`, etc.). +// Every entry MUST end in `/` so the prefix can only match a subpath +// — a bare prefix like `@storybook/test` would also subsume +// `@storybook/test-runner` and `@storybook/testing-library`, both of +// which are already enumerated in the exact set above and may diverge +// independently in the future. +export const TEST_LIBRARY_IMPORT_SOURCE_PREFIXES: ReadonlyArray = [ + "vitest/", + "@vitest/", + "@jest/", + "@testing-library/", + "@playwright/", + "@storybook/test/", + "@storybook/test-runner/", + "@storybook/testing-library/", + "@cypress/", + "@nuxt/test-utils/", +]; + +// Callees that strongly signal an ordered UI-driving sequence — +// render/assert/click/assert flows that intentionally serialize +// each `await` to preserve cause-and-effect, NOT independent async +// I/O that should be parallelized with `Promise.all`. Membership is +// checked against the rightmost identifier in the callee chain so +// both `await render(...)` and `await screen.findByRole(...)` match. +export const ORDERED_UI_FLOW_CALLEE_NAMES: ReadonlySet = new Set([ + "render", + "rerender", + "renderHook", + "renderToString", + "renderToStaticMarkup", + "act", + "click", + "dblClick", + "dblclick", + "tripleClick", + "tap", + "press", + "longPress", + "type", + "clear", + "fill", + "focus", + "blur", + "hover", + "unhover", + "check", + "uncheck", + "selectOption", + "selectOptions", + "setChecked", + "setInputFiles", + "scrollIntoViewIfNeeded", + "dragTo", + "dragAndDrop", + "drop", + "evaluate", + "evaluateHandle", + "waitFor", + "waitForLoadState", + "waitForSelector", + "waitForURL", + "waitForResponse", + "waitForRequest", + "waitForEvent", + "waitForFunction", + "waitForElementToBeRemoved", + "goto", + "goBack", + "goForward", + "reload", + "screenshot", + "snapshot", + "toMatchSnapshot", + "toMatchInlineSnapshot", + "expect", + "expectTypeOf", + "step", + "describe", + "test", + "it", + "beforeAll", + "beforeEach", + "afterAll", + "afterEach", + "play", + "userEvent", + "screen", + "within", +]); + +// `findBy*` / `findAllBy*` are the Testing Library async query family +// — `findByRole`, `findByText`, etc. Treat any callee whose rightmost +// identifier starts with `findBy` or `findAllBy` as a UI flow call, +// without having to enumerate every suffix. +export const ORDERED_UI_FLOW_CALLEE_PREFIXES: ReadonlyArray = ["findBy", "findAllBy"]; + +// Callees that signal intentional pacing — animation tweens, demo +// sequencing, polling waits, throttles. Awaits on these are +// inherently serial: parallelizing a `sleep(200)` and a `sleep(400)` +// would defeat the point. Matches the rightmost identifier in the +// callee chain (so `await timer.tick(16)` matches "tick" and +// `await animations.spring(...)` matches "spring"). Kept in sync with +// the sister set in `async-await-in-loop`. +export const INTENTIONAL_SEQUENCING_CALLEE_NAMES: ReadonlySet = new Set([ + "sleep", + "delay", + "wait", + "pause", + "throttle", + "debounce", + "tick", + "nextTick", + "advanceTimersByTime", + "advanceTimersByTimeAsync", + "runAllTimers", + "runAllTimersAsync", + "runOnlyPendingTimers", + "runOnlyPendingTimersAsync", + "setTimeout", + "setInterval", + "requestAnimationFrame", + "requestIdleCallback", + "animate", + "transition", + "spring", + "tween", + "stagger", + "sequence", + "timeline", + "scrub", +]); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/js-performance/async-parallel.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/js-performance/async-parallel.ts index d67720a8..f80fe096 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/js-performance/async-parallel.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/js-performance/async-parallel.ts @@ -1,6 +1,13 @@ -import { TEST_FILE_PATTERN } from "../../constants/js.js"; +import { + BROWSER_TEST_FILE_PATTERN, + INTENTIONAL_SEQUENCING_CALLEE_NAMES, + ORDERED_UI_FLOW_CALLEE_NAMES, + ORDERED_UI_FLOW_CALLEE_PREFIXES, +} from "../../constants/js.js"; import { SEQUENTIAL_AWAIT_THRESHOLD } from "../../constants/thresholds.js"; import { defineRule } from "../../utils/define-rule.js"; +import { getCalleeIdentifierTrail } from "../../utils/get-callee-identifier-trail.js"; +import { isTestLibraryImportSource } from "../../utils/is-test-library-import-source.js"; import { walkAst } from "../../utils/walk-ast.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { Rule } from "../../utils/rule.js"; @@ -8,6 +15,58 @@ import type { RuleContext } from "../../utils/rule-context.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +const getAwaitedCall = (statement: EsTreeNode): EsTreeNode | null => { + if (isNodeOfType(statement, "VariableDeclaration")) { + const declarator = statement.declarations?.[0]; + if (declarator && isNodeOfType(declarator.init, "AwaitExpression")) { + return declarator.init.argument ?? null; + } + } + if ( + isNodeOfType(statement, "ExpressionStatement") && + isNodeOfType(statement.expression, "AwaitExpression") + ) { + return statement.expression.argument ?? null; + } + return null; +}; + +const isOrderedUiFlowName = (name: string): boolean => { + if (ORDERED_UI_FLOW_CALLEE_NAMES.has(name)) return true; + return ORDERED_UI_FLOW_CALLEE_PREFIXES.some((prefix) => name.startsWith(prefix)); +}; + +// True when ANY identifier in the callee chain — leaf method, owning +// object, or bare callee — names an ordered UI-flow operation. So +// `await screen.findByRole(...)`, `await page.locator(...).click()`, +// and `await render(...)` all qualify. +const isOrderedUiFlowAwait = (awaitedCall: EsTreeNode | null): boolean => { + if (!awaitedCall) return false; + const trail = getCalleeIdentifierTrail(awaitedCall); + return trail.some(isOrderedUiFlowName); +}; + +const isIntentionalSequencingAwait = (awaitedCall: EsTreeNode | null): boolean => { + if (!awaitedCall) return false; + const trail = getCalleeIdentifierTrail(awaitedCall); + return trail.some((name) => INTENTIONAL_SEQUENCING_CALLEE_NAMES.has(name)); +}; + +// Skip a consecutive-await block whenever any one of its awaits is an +// ordered-UI-flow call or an intentional sequencing call. A single +// `await page.click(...)` in the middle of three otherwise-independent +// awaits is enough to mark the whole sequence as deliberately +// serialized — collapsing it into `Promise.all([...])` would change +// observable behavior. +const sequenceContainsSerializationSignal = (statements: EsTreeNode[]): boolean => { + for (const statement of statements) { + const awaitedCall = getAwaitedCall(statement); + if (isOrderedUiFlowAwait(awaitedCall)) return true; + if (isIntentionalSequencingAwait(awaitedCall)) return true; + } + return false; +}; + const reportIfIndependent = (statements: EsTreeNode[], context: RuleContext): void => { const declaredNames = new Set(); @@ -39,21 +98,42 @@ const reportIfIndependent = (statements: EsTreeNode[], context: RuleContext): vo export const asyncParallel = defineRule({ id: "async-parallel", + // `test-noise` opts every file `isTestFilePath(...)` recognises + // (`*.test.*`, `*.spec.*`, `__tests__/`, `e2e/`, `playwright/`, + // `cypress/`, fixtures, mocks, Windows-slashed equivalents, …) out + // of this rule via `mergeAndFilterDiagnostics`. The in-rule guards + // below handle the cases that path matching can't see: Vitest + // browser fixtures (`*.browser.tsx`), production-co-located helpers + // that import a test library, and ordered render→assert→click + // flows. Allow intentional animation/demo pacing or a documented + // inline `// react-doctor-disable-next-line` opt-out. + tags: ["test-noise"], severity: "warn", recommendation: "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently", create: (context: RuleContext) => { const filename = context.getFilename?.() ?? ""; - const isTestFile = TEST_FILE_PATTERN.test(filename); + const isBrowserTestFile = BROWSER_TEST_FILE_PATTERN.test(filename); + let hasTestLibraryImport = false; + + const shouldSkipFile = (): boolean => isBrowserTestFile || hasTestLibraryImport; return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + if (hasTestLibraryImport) return; + if (isTestLibraryImportSource(node.source?.value)) { + hasTestLibraryImport = true; + } + }, BlockStatement(node: EsTreeNodeOfType<"BlockStatement">) { - if (isTestFile) return; + if (shouldSkipFile()) return; const consecutiveAwaitStatements: EsTreeNode[] = []; const flushConsecutiveAwaits = (): void => { if (consecutiveAwaitStatements.length >= SEQUENTIAL_AWAIT_THRESHOLD) { - reportIfIndependent(consecutiveAwaitStatements, context); + if (!sequenceContainsSerializationSignal(consecutiveAwaitStatements)) { + reportIfIndependent(consecutiveAwaitStatements, context); + } } consecutiveAwaitStatements.length = 0; }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-callee-identifier-trail.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-callee-identifier-trail.ts new file mode 100644 index 00000000..e3e4c690 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-callee-identifier-trail.ts @@ -0,0 +1,43 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +// Walks a `CallExpression`'s callee — including any intermediate +// member accesses, optional-chains, and parenthesised sub-calls — and +// yields every identifier name encountered (leaf-first). For +// `await screen.findByRole("button").focus()` the trail is +// `["focus", "findByRole", "screen"]`; for `await render(...)` it's +// just `["render"]`. Used by `async-parallel` to pattern-match the +// rightmost identifier against UI-flow / sequencing tables without +// having to enumerate every possible chain shape. +export const getCalleeIdentifierTrail = (call: EsTreeNode | null | undefined): string[] => { + // Optional-chained awaits (`await page?.click()`) parse as a + // `ChainExpression` wrapping the underlying `CallExpression`, so the + // entry guard must peel any wrapping chain layers before checking + // for the call/new — otherwise the trail would silently come back + // empty and the UI-flow / sequencing signal would be missed. + let entry: EsTreeNode | null | undefined = call; + while (isNodeOfType(entry, "ChainExpression")) entry = entry.expression; + if (!isNodeOfType(entry, "CallExpression") && !isNodeOfType(entry, "NewExpression")) return []; + const trail: string[] = []; + let cursor: EsTreeNode | null | undefined = entry.callee; + while (cursor) { + if (isNodeOfType(cursor, "ChainExpression")) { + cursor = cursor.expression; + continue; + } + if (isNodeOfType(cursor, "MemberExpression")) { + if (isNodeOfType(cursor.property, "Identifier")) trail.push(cursor.property.name); + cursor = cursor.object; + continue; + } + if (isNodeOfType(cursor, "CallExpression")) { + cursor = cursor.callee; + continue; + } + if (isNodeOfType(cursor, "Identifier")) { + trail.push(cursor.name); + } + break; + } + return trail; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-test-library-import-source.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-test-library-import-source.ts new file mode 100644 index 00000000..dd00b55c --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-test-library-import-source.ts @@ -0,0 +1,17 @@ +import { + TEST_LIBRARY_IMPORT_SOURCES, + TEST_LIBRARY_IMPORT_SOURCE_PREFIXES, +} from "../constants/js.js"; + +// Returns true when an `import ... from ""` (or +// `require("")`) module identifier belongs to a known test +// runner, browser-test harness, assertion library, or interaction +// driver. Used to suppress noisy "this should be parallelized" advice +// in fixtures that import these libraries but don't match the shared +// `isTestFilePath` path heuristic (e.g. `src/test-utils.ts`, +// component fixtures co-located with production code). +export const isTestLibraryImportSource = (source: unknown): boolean => { + if (typeof source !== "string" || source.length === 0) return false; + if (TEST_LIBRARY_IMPORT_SOURCES.has(source)) return true; + return TEST_LIBRARY_IMPORT_SOURCE_PREFIXES.some((prefix) => source.startsWith(prefix)); +}; diff --git a/packages/react-doctor/tests/merge-and-filter-diagnostics.test.ts b/packages/react-doctor/tests/merge-and-filter-diagnostics.test.ts index 93743ca4..40c22f1b 100644 --- a/packages/react-doctor/tests/merge-and-filter-diagnostics.test.ts +++ b/packages/react-doctor/tests/merge-and-filter-diagnostics.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { afterAll, describe, expect, it } from "vite-plus/test"; import type { Diagnostic } from "@react-doctor/types"; -import { createNodeReadFileLinesSync, mergeAndFilterDiagnostics } from "@react-doctor/core"; +import { + clearAutoSuppressionCaches, + createNodeReadFileLinesSync, + mergeAndFilterDiagnostics, +} from "@react-doctor/core"; import { buildDiagnostic, writeFile } from "./regressions/_helpers.js"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-merge-and-filter-")); @@ -64,3 +68,79 @@ describe("mergeAndFilterDiagnostics — respectInlineDisables option", () => { expect(filtered).toHaveLength(0); }); }); + +describe("mergeAndFilterDiagnostics — test-noise tag auto-suppression for async-parallel", () => { + const projectDir = path.join(tempRoot, "test-noise-async-parallel"); + const readNoop = () => null; + const asyncParallelDiagnostic = (filePath: string): Diagnostic => + buildDiagnostic({ + rule: "async-parallel", + filePath, + line: 1, + column: 1, + }); + + it("auto-suppresses async-parallel in `*.test.tsx` files", () => { + clearAutoSuppressionCaches(); + const filtered = mergeAndFilterDiagnostics( + [asyncParallelDiagnostic("src/dashboard.test.tsx")], + projectDir, + null, + readNoop, + { respectInlineDisables: false }, + ); + expect(filtered).toHaveLength(0); + }); + + it("auto-suppresses async-parallel inside `__tests__/` directories", () => { + clearAutoSuppressionCaches(); + const filtered = mergeAndFilterDiagnostics( + [asyncParallelDiagnostic("src/utils/__tests__/load-data.ts")], + projectDir, + null, + readNoop, + { respectInlineDisables: false }, + ); + expect(filtered).toHaveLength(0); + }); + + it("auto-suppresses async-parallel inside Playwright/Cypress/e2e directories", () => { + clearAutoSuppressionCaches(); + const filtered = mergeAndFilterDiagnostics( + [ + asyncParallelDiagnostic("playwright/checkout.spec.ts"), + asyncParallelDiagnostic("cypress/e2e/login.cy.ts"), + asyncParallelDiagnostic("e2e/onboarding.ts"), + ], + projectDir, + null, + readNoop, + { respectInlineDisables: false }, + ); + expect(filtered).toHaveLength(0); + }); + + it("auto-suppresses async-parallel for Windows-slashed test paths", () => { + clearAutoSuppressionCaches(); + const filtered = mergeAndFilterDiagnostics( + [asyncParallelDiagnostic("src\\components\\Button.test.tsx")], + projectDir, + null, + readNoop, + { respectInlineDisables: false }, + ); + expect(filtered).toHaveLength(0); + }); + + it("still surfaces async-parallel in plain production files", () => { + clearAutoSuppressionCaches(); + const filtered = mergeAndFilterDiagnostics( + [asyncParallelDiagnostic("src/server/load-dashboard.ts")], + projectDir, + null, + readNoop, + { respectInlineDisables: false }, + ); + expect(filtered).toHaveLength(1); + }); +}); diff --git a/packages/react-doctor/tests/regressions/js-performance-rules.test.ts b/packages/react-doctor/tests/regressions/js-performance-rules.test.ts index 3fd6de2d..32688d35 100644 --- a/packages/react-doctor/tests/regressions/js-performance-rules.test.ts +++ b/packages/react-doctor/tests/regressions/js-performance-rules.test.ts @@ -1034,3 +1034,332 @@ describe("js-length-check-first", () => { expect(hits).toHaveLength(1); }); }); +describe("async-parallel", () => { + it("flags three independent sequential awaits in production code", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-independent-production", { + files: { + "src/load-dashboard.ts": ` + declare const fetchUser: () => Promise<{ id: string }>; + declare const fetchOrders: () => Promise>; + declare const fetchInvoices: () => Promise>; + + export const loadDashboard = async () => { + const user = await fetchUser(); + const orders = await fetchOrders(); + const invoices = await fetchInvoices(); + return { user, orders, invoices }; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(1); + expect(hits[0].message).toContain("sequential await"); + }); + + it("does not flag render → expect → click → expect ordered UI flows even in non-test paths", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-ordered-ui-flow", { + files: { + "src/settings-panels.browser.tsx": ` + declare const render: (jsx: unknown) => Promise<{ container: HTMLElement }>; + declare const screen: { + findByRole: (role: string, opts?: object) => Promise; + findByText: (text: string) => Promise; + }; + declare const userEvent: { click: (element: HTMLElement) => Promise }; + + export const runFlow = async () => { + const { container } = await render(null as unknown); + const saveButton = await screen.findByRole("button", { name: "Save" }); + await userEvent.click(saveButton); + const confirmation = await screen.findByText("Saved"); + return { container, confirmation }; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("does not flag sequences in files that import a known test library", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-test-library-import", { + files: { + "src/checkout-fixture.ts": ` + import { test, expect } from "@playwright/test"; + + declare const page: { + goto: (url: string) => Promise; + getByRole: (role: string) => { click: () => Promise; fill: (value: string) => Promise }; + }; + declare const fetchA: () => Promise; + declare const fetchB: () => Promise; + declare const fetchC: () => Promise; + + export const runCheckout = async () => { + const a = await fetchA(); + const b = await fetchB(); + const c = await fetchC(); + return a + b + c; + }; + + test("noop", async () => { + await page.goto("/checkout"); + expect(a).toBeDefined(); + }); + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("does not flag sequences in files that import a Testing Library helper", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-testing-library-import", { + files: { + "src/render-helpers.tsx": ` + import { render } from "@testing-library/react"; + + declare const fetchA: () => Promise; + declare const fetchB: () => Promise; + declare const fetchC: () => Promise; + + export const seed = async () => { + const a = await fetchA(); + const b = await fetchB(); + const c = await fetchC(); + return { a, b, c, render }; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("does not flag sequences in files that import vitest via a subpath", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-vitest-subpath", { + files: { + "src/browser-setup.ts": ` + import { page } from "vitest/browser"; + + declare const fetchA: () => Promise; + declare const fetchB: () => Promise; + declare const fetchC: () => Promise; + + export const seed = async () => { + const a = await fetchA(); + const b = await fetchB(); + const c = await fetchC(); + return { a, b, c, page }; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("does not flag intentional animation/demo pacing via sleep-like awaits", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-animation-pacing", { + files: { + "src/intro-demo.ts": ` + declare const fadeIn: (selector: string) => Promise; + declare const animate: (selector: string, frames: object) => Promise; + declare const sleep: (ms: number) => Promise; + + export const playIntro = async () => { + await fadeIn(".logo"); + await sleep(400); + await animate(".tagline", { opacity: 1 }); + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("respects documented inline suppression even when the sequence is otherwise independent", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-inline-suppression", { + files: { + "src/seed.ts": ` + declare const fetchA: () => Promise; + declare const fetchB: () => Promise; + declare const fetchC: () => Promise; + + export const seed = async () => { + // oxlint-disable-next-line react-doctor/async-parallel -- intentionally serial for rate limits + const a = await fetchA(); + const b = await fetchB(); + const c = await fetchC(); + return a + b + c; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("still flags independent sequences when only later awaits are UI flow calls", async () => { + // The first three awaits form an independent batch BEFORE any UI flow + // call appears — the rule should still fire on that batch, even though + // there's a later `await page.click()` in the same function. + const projectDir = setupReactProject(tempRoot, "async-parallel-independent-prefix", { + files: { + "src/prep.ts": ` + declare const fetchA: () => Promise; + declare const fetchB: () => Promise; + declare const fetchC: () => Promise; + declare const teardown: () => void; + declare const page: { click: (selector: string) => Promise }; + + export const prep = async () => { + const a = await fetchA(); + const b = await fetchB(); + const c = await fetchC(); + teardown(); + await page.click(".start"); + return a + b + c; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(1); + }); + + it("does not flag Playwright locator chains nested in member expressions", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-locator-chain", { + files: { + "src/spec.ts": ` + import { test } from "@playwright/test"; + + declare const page: { + locator: (selector: string) => { + click: () => Promise; + fill: (text: string) => Promise; + press: (key: string) => Promise; + }; + }; + + test("ordered", async () => { + await page.locator("input").fill("hello"); + await page.locator("input").press("Enter"); + await page.locator(".submit").click(); + }); + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("does not flag optional-chained UI flow callees (await page?.click())", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-optional-chain-ui-flow", { + files: { + "src/optional-chain-flow.ts": ` + declare const page: { click?: (selector: string) => Promise } | undefined; + declare const fetchA: () => Promise; + declare const fetchB: () => Promise; + + export const runFlow = async () => { + const a = await fetchA(); + const b = await fetchB(); + await page?.click("input"); + return a + b; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("does not subsume `@storybook/test-runner` / `@storybook/testing-library` under a bare `@storybook/test` prefix", async () => { + // Regression guard for the prefix-without-trailing-slash bug: a + // bare `@storybook/test` entry would also match every + // `@storybook/test-runner` / `@storybook/testing-library` import, + // collapsing three independently-versioned packages into one + // catch-all. The exact-set membership still covers the canonical + // identifiers; this test pins the boundary. + const projectDir = setupReactProject(tempRoot, "async-parallel-storybook-prefix-boundary", { + files: { + "src/storybook-runner-import.ts": ` + import { TestRunnerConfig } from "@storybook/test-runner"; + + declare const fetchA: () => Promise; + declare const fetchB: () => Promise; + declare const fetchC: () => Promise; + + export const seed = async () => { + const a = await fetchA(); + const b = await fetchB(); + const c = await fetchC(); + return { a, b, c, TestRunnerConfig }; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("does not flag `@storybook/test/spy` subpath imports either", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-storybook-test-subpath", { + files: { + "src/spy-helpers.ts": ` + import { fn } from "@storybook/test/spy"; + + declare const fetchA: () => Promise; + declare const fetchB: () => Promise; + declare const fetchC: () => Promise; + + export const seed = async () => { + const a = await fetchA(); + const b = await fetchB(); + const c = await fetchC(); + return { a, b, c, fn }; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); + + it("only flags consecutive independent awaits, not unrelated dependent ones", async () => { + const projectDir = setupReactProject(tempRoot, "async-parallel-dependent-chain", { + files: { + "src/load.ts": ` + declare const fetchUser: () => Promise<{ id: string }>; + declare const fetchProfile: (userId: string) => Promise<{ name: string }>; + declare const fetchPosts: (userId: string) => Promise; + + export const load = async () => { + const user = await fetchUser(); + const profile = await fetchProfile(user.id); + const posts = await fetchPosts(user.id); + return { profile, posts }; + }; + `, + }, + }); + + const hits = await collectRuleHits(projectDir, "async-parallel"); + expect(hits).toHaveLength(0); + }); +});