From a9647b87bf8ffcfc94e5129ce9befca77351d402 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 11 Aug 2026 22:56:30 -0700 Subject: [PATCH 01/24] Move GitHub Actions and scripts tests into tests/tooling/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These 19 files test .github/actions, .github/libs, .github/scripts and scripts/ — build and CI tooling rather than app code. Grouping them in one directory lets every config that needs to treat them differently (Jest, tsconfig, ESLint, the Bun test runner, CI path filters) use a single glob instead of enumerating each file. Renamed to the *.test.ts suffix Bun's test runner discovers, so the whole directory can be passed to `bun test` without listing files. Pure move: no file contents changed. --- config/eslint/eslint.seatbelt.tsv | 18 +++++++++--------- .../CIGitLogic.test.ts} | 0 .../DeployChecklistUtils.test.ts} | 0 tests/{unit/GitTest.ts => tooling/Git.test.ts} | 0 .../GitUtils.test.ts} | 0 .../GithubUtils.test.ts} | 0 .../ReactCompilerComplianceCheck.test.ts} | 0 .../awaitStagingDeploys.test.ts} | 0 .../bumpVersion.test.ts} | 0 .../checkDeployBlockers.test.ts} | 0 .../createOrUpdateDeployChecklist.test.ts} | 0 .../detectReactComponent.test.ts | 0 .../failureNotifier.test.ts} | 0 .../getPullRequestIncrementalChanges.test.ts} | 0 .../isAuthorizedContributor.test.ts} | 0 .../isDeployChecklistLocked.test.ts} | 0 .../markPullRequestsAsDeployed.test.ts} | 0 .../postOrReplaceComment.test.ts} | 0 .../versionUpdater.test.ts} | 0 .../waitForPreviousRuns.test.ts} | 0 20 files changed, 9 insertions(+), 9 deletions(-) rename tests/{unit/CIGitLogicTest.ts => tooling/CIGitLogic.test.ts} (100%) rename tests/{unit/DeployChecklistUtilsTest.ts => tooling/DeployChecklistUtils.test.ts} (100%) rename tests/{unit/GitTest.ts => tooling/Git.test.ts} (100%) rename tests/{unit/GitUtilsTest.ts => tooling/GitUtils.test.ts} (100%) rename tests/{unit/GithubUtilsTest.ts => tooling/GithubUtils.test.ts} (100%) rename tests/{unit/ReactCompilerComplianceCheckTest.ts => tooling/ReactCompilerComplianceCheck.test.ts} (100%) rename tests/{unit/awaitStagingDeploysTest.ts => tooling/awaitStagingDeploys.test.ts} (100%) rename tests/{unit/BumpVersionTest.ts => tooling/bumpVersion.test.ts} (100%) rename tests/{unit/checkDeployBlockersTest.ts => tooling/checkDeployBlockers.test.ts} (100%) rename tests/{unit/createOrUpdateDeployChecklistTest.ts => tooling/createOrUpdateDeployChecklist.test.ts} (100%) rename tests/{actions => tooling}/detectReactComponent.test.ts (100%) rename tests/{unit/FailureNotifierTest.ts => tooling/failureNotifier.test.ts} (100%) rename tests/{unit/getPullRequestIncrementalChangesTest.ts => tooling/getPullRequestIncrementalChanges.test.ts} (100%) rename tests/{unit/isAuthorizedContributorTest.ts => tooling/isAuthorizedContributor.test.ts} (100%) rename tests/{unit/isDeployChecklistLockedTest.ts => tooling/isDeployChecklistLocked.test.ts} (100%) rename tests/{unit/markPullRequestsAsDeployedTest.ts => tooling/markPullRequestsAsDeployed.test.ts} (100%) rename tests/{unit/postOrReplaceComment.ts => tooling/postOrReplaceComment.test.ts} (100%) rename tests/{unit/versionUpdaterTest.ts => tooling/versionUpdater.test.ts} (100%) rename tests/{unit/waitForPreviousRunsTest.ts => tooling/waitForPreviousRuns.test.ts} (100%) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 71c23ede5e25..98e1e9851432 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1676,14 +1676,14 @@ "../../tests/unit/ExpenseRuleUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/ExportDownloadStatusModalTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/ExportOnyxStateTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 12 -"../../tests/unit/FailureNotifierTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/tooling/failureNotifier.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/FileUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 "../../tests/unit/FlashListTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/FocusTrapForModalTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/FormulaTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 "../../tests/unit/FormulaTest.ts" "no-restricted-imports" 1 "../../tests/unit/FraudProtectionTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/unit/GithubUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../tests/tooling/GithubUtils.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts" "no-restricted-imports" 1 "../../tests/unit/HrUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -1763,9 +1763,9 @@ "../../tests/unit/WorkflowUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../tests/unit/WorkspaceReportFieldUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/WorkspacesSettingsUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 23 -"../../tests/unit/awaitStagingDeploysTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../tests/tooling/awaitStagingDeploys.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/canEditFieldOfMoneyRequestTest.ts" "@typescript-eslint/no-deprecated/randomReportAction.originalMessage" 3 -"../../tests/unit/checkDeployBlockersTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../tests/tooling/checkDeployBlockers.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/cleanupAfterSkipConfirmSubmitTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/unit/components/Charts/VictoryTheme.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1777,7 +1777,7 @@ "../../tests/unit/components/ScreenWrapperPreventRemoveTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/compoundParamsKeyTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/unit/createOrUpdateDeployChecklistTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/tooling/createOrUpdateDeployChecklist.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/deferredLayoutWriteTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/dismissModalAndOpenReportInInboxTabTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/findUnusedStylesTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -1827,8 +1827,8 @@ "../../tests/unit/hooks/useTimeSensitiveLockedBankAccount.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/hooks/useTransactionThreadReportIDs.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/inlineEditing/editableCellHooks.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/unit/isAuthorizedContributorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/unit/isDeployChecklistLockedTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/tooling/isAuthorizedContributor.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../tests/tooling/isDeployChecklistLocked.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/isReportMessageAttachmentTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/isReportOpenOrUnsubmittedTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/keyboard/AndroidKeyboardUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1841,7 +1841,7 @@ "../../tests/unit/libs/TravelUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/libs/getClipboardTextTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/libs/navigateAfterOnboarding.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/unit/markPullRequestsAsDeployedTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../tests/tooling/markPullRequestsAsDeployed.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/navigateAfterOnboardingTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/navigateAfterOnboardingTest.ts" "no-restricted-imports" 1 "../../tests/unit/navigateToWorkspacesPageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 14 @@ -1886,7 +1886,7 @@ "../../tests/unit/useTodoCountsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/useTodosTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/useTransactionViolationsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 -"../../tests/unit/waitForPreviousRunsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../tests/tooling/waitForPreviousRuns.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/utils/collections/card.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/utils/collections/paymentMethods.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/utils/collections/reportActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/tests/unit/CIGitLogicTest.ts b/tests/tooling/CIGitLogic.test.ts similarity index 100% rename from tests/unit/CIGitLogicTest.ts rename to tests/tooling/CIGitLogic.test.ts diff --git a/tests/unit/DeployChecklistUtilsTest.ts b/tests/tooling/DeployChecklistUtils.test.ts similarity index 100% rename from tests/unit/DeployChecklistUtilsTest.ts rename to tests/tooling/DeployChecklistUtils.test.ts diff --git a/tests/unit/GitTest.ts b/tests/tooling/Git.test.ts similarity index 100% rename from tests/unit/GitTest.ts rename to tests/tooling/Git.test.ts diff --git a/tests/unit/GitUtilsTest.ts b/tests/tooling/GitUtils.test.ts similarity index 100% rename from tests/unit/GitUtilsTest.ts rename to tests/tooling/GitUtils.test.ts diff --git a/tests/unit/GithubUtilsTest.ts b/tests/tooling/GithubUtils.test.ts similarity index 100% rename from tests/unit/GithubUtilsTest.ts rename to tests/tooling/GithubUtils.test.ts diff --git a/tests/unit/ReactCompilerComplianceCheckTest.ts b/tests/tooling/ReactCompilerComplianceCheck.test.ts similarity index 100% rename from tests/unit/ReactCompilerComplianceCheckTest.ts rename to tests/tooling/ReactCompilerComplianceCheck.test.ts diff --git a/tests/unit/awaitStagingDeploysTest.ts b/tests/tooling/awaitStagingDeploys.test.ts similarity index 100% rename from tests/unit/awaitStagingDeploysTest.ts rename to tests/tooling/awaitStagingDeploys.test.ts diff --git a/tests/unit/BumpVersionTest.ts b/tests/tooling/bumpVersion.test.ts similarity index 100% rename from tests/unit/BumpVersionTest.ts rename to tests/tooling/bumpVersion.test.ts diff --git a/tests/unit/checkDeployBlockersTest.ts b/tests/tooling/checkDeployBlockers.test.ts similarity index 100% rename from tests/unit/checkDeployBlockersTest.ts rename to tests/tooling/checkDeployBlockers.test.ts diff --git a/tests/unit/createOrUpdateDeployChecklistTest.ts b/tests/tooling/createOrUpdateDeployChecklist.test.ts similarity index 100% rename from tests/unit/createOrUpdateDeployChecklistTest.ts rename to tests/tooling/createOrUpdateDeployChecklist.test.ts diff --git a/tests/actions/detectReactComponent.test.ts b/tests/tooling/detectReactComponent.test.ts similarity index 100% rename from tests/actions/detectReactComponent.test.ts rename to tests/tooling/detectReactComponent.test.ts diff --git a/tests/unit/FailureNotifierTest.ts b/tests/tooling/failureNotifier.test.ts similarity index 100% rename from tests/unit/FailureNotifierTest.ts rename to tests/tooling/failureNotifier.test.ts diff --git a/tests/unit/getPullRequestIncrementalChangesTest.ts b/tests/tooling/getPullRequestIncrementalChanges.test.ts similarity index 100% rename from tests/unit/getPullRequestIncrementalChangesTest.ts rename to tests/tooling/getPullRequestIncrementalChanges.test.ts diff --git a/tests/unit/isAuthorizedContributorTest.ts b/tests/tooling/isAuthorizedContributor.test.ts similarity index 100% rename from tests/unit/isAuthorizedContributorTest.ts rename to tests/tooling/isAuthorizedContributor.test.ts diff --git a/tests/unit/isDeployChecklistLockedTest.ts b/tests/tooling/isDeployChecklistLocked.test.ts similarity index 100% rename from tests/unit/isDeployChecklistLockedTest.ts rename to tests/tooling/isDeployChecklistLocked.test.ts diff --git a/tests/unit/markPullRequestsAsDeployedTest.ts b/tests/tooling/markPullRequestsAsDeployed.test.ts similarity index 100% rename from tests/unit/markPullRequestsAsDeployedTest.ts rename to tests/tooling/markPullRequestsAsDeployed.test.ts diff --git a/tests/unit/postOrReplaceComment.ts b/tests/tooling/postOrReplaceComment.test.ts similarity index 100% rename from tests/unit/postOrReplaceComment.ts rename to tests/tooling/postOrReplaceComment.test.ts diff --git a/tests/unit/versionUpdaterTest.ts b/tests/tooling/versionUpdater.test.ts similarity index 100% rename from tests/unit/versionUpdaterTest.ts rename to tests/tooling/versionUpdater.test.ts diff --git a/tests/unit/waitForPreviousRunsTest.ts b/tests/tooling/waitForPreviousRuns.test.ts similarity index 100% rename from tests/unit/waitForPreviousRunsTest.ts rename to tests/tooling/waitForPreviousRuns.test.ts From 24a4f7469d2da60bb0192e62d516a5b1d45ea404 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 11 Aug 2026 22:58:19 -0700 Subject: [PATCH 02/24] Wire tests/tooling/ into Bun, Jest, TypeScript, ESLint, knip and CI Adds the plumbing that lets tests/tooling/ run under `bun test` instead of Jest: - tests/tooling/tsconfig.json type-checks the directory with @types/bun (bun:test's ambient types conflict with the @types/jest the root config uses), and the root tsconfig excludes it so nothing is checked twice with the wrong types. - tests/tooling/setup.ts is preloaded to default GITHUB_REPOSITORY, mirroring jest/setup.ts for the files Jest still owns. - `test:tooling` runs the whole directory with --isolate, which gives each file a fresh module registry, so module-level state and mock.module() calls can't leak between files the way they would in Bun's default single-registry mode. - Jest ignores the directory, ESLint parses it with the new tsconfig, knip treats the files as entry points (nothing imports them), and the Bun CI job runs the new script. --- .github/workflows/bunTests.yml | 8 ++++++++ config/eslint/eslint.config.mjs | 18 ++++++++++++++++++ jest.config.js | 5 ++++- knip.json | 1 + package.json | 5 +++-- tests/tooling/setup.ts | 7 +++++++ tests/tooling/tsconfig.json | 11 +++++++++++ tsconfig.json | 11 ++++++++++- 8 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 tests/tooling/setup.ts create mode 100644 tests/tooling/tsconfig.json diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml index 2df7576e070d..6732d86b9a12 100644 --- a/.github/workflows/bunTests.yml +++ b/.github/workflows/bunTests.yml @@ -7,6 +7,11 @@ on: paths: - 'server/**' - 'src/**' + - 'tests/tooling/**' + - '.github/actions/javascript/**' + - '.github/libs/**' + - '.github/scripts/**' + - 'scripts/**' - 'bunfig.toml' - '.bun-version' - 'package.json' @@ -31,6 +36,9 @@ jobs: - name: Run Bun tests run: npm run test:bun + - name: Run tooling tests (.github + scripts) + run: npm run test:tooling + - name: Build victory-chart-renderer Linux x64 binary run: npm run server:vcr:build:linux diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index e9d5af4950e5..de5208a6e29e 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -699,6 +699,24 @@ const config = defineConfig([ }, }, + { + // Excluded from the root tsconfig.json because they need @types/bun, which conflicts with the @types/jest + // used everywhere else. + files: ['tests/tooling/**/*.ts'], + languageOptions: { + parserOptions: { + project: path.resolve(projectRoot, 'tests/tooling/tsconfig.json'), + projectService: false, + }, + }, + rules: { + // bun-types declares `expect(...).resolves`/`.rejects` matchers as returning `void` even though Bun's + // own docs recommend (and its runtime requires) awaiting them, so this rule false-positives on that + // pattern here. See https://github.com/oven-sh/bun/pull/23425. + '@typescript-eslint/await-thenable': 'off', + }, + }, + { files: ['server/victory-chart-renderer/**/*.ts', 'server/victory-chart-renderer/**/*.tsx'], languageOptions: { diff --git a/jest.config.js b/jest.config.js index eed0ea91fec1..0ef4f2bd8fe5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,7 +26,10 @@ module.exports = { // Prevent Babel from transforming worklets in this file so they are treated as normal functions, otherwise FormatSelectionUtilsTest won't run. '/node_modules/@expensify/react-native-live-markdown/lib/commonjs/parseExpensiMark.js', ], - testPathIgnorePatterns: ['/node_modules'], + // tests/tooling/ covers .github/ and scripts/ and runs under `bun test` instead (see the `test:tooling` npm + // script), so those files import `bun:test` rather than Jest's globals. They aren't in testMatch above, and + // this keeps them out even if a future testMatch entry broadens to all of tests/. + testPathIgnorePatterns: ['/node_modules', '/tests/tooling/'], // .worktrees/ and .claude/worktrees/ hold parallel git worktrees a developer may check out locally. // Each one carries its own modules/hybrid-app/package.json, which trips // jest-haste-map's "duplicate package name" assertion. Skip them entirely. diff --git a/knip.json b/knip.json index 14e17865496e..225d716d71b9 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "web/proxy.ts", "config/rsbuild/**/*.{js,mjs,cjs,ts}", ".github/scripts/**/*.ts", + "tests/tooling/**/*.ts", ".github/actions/javascript/**/*.ts", ".storybook/**/*.{js,ts,tsx}", "metro.config.js", diff --git a/package.json b/package.json index c5a17c128bce..15c30c781908 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "test:verbose": "TZ=utc NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" JEST_VERBOSE=true jest", "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand", "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", - "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/victory-chart-renderer/tsconfig.json", - "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/tsconfig.json --incremental --tsBuildInfoFile server/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/victory-chart-renderer/tsconfig.json --incremental --tsBuildInfoFile server/victory-chart-renderer/tsconfig.tsgo.tsbuildinfo", + "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/victory-chart-renderer/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p tests/tooling/tsconfig.json", + "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/tsconfig.json --incremental --tsBuildInfoFile server/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/victory-chart-renderer/tsconfig.json --incremental --tsBuildInfoFile server/victory-chart-renderer/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p tests/tooling/tsconfig.json --incremental --tsBuildInfoFile tests/tooling/tsconfig.tsgo.tsbuildinfo", "lint": "./scripts/lint.sh", "lint-changed": "./scripts/lintChanged.sh", "lint-watch": "onchange '**/*.{js,jsx,ts,tsx,mjs,cjs}' -- ./scripts/lint.sh {{changed}}", @@ -82,6 +82,7 @@ "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer --", "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer --", "test:bun": "bun test --concurrent --max-concurrency 7", + "test:tooling": "bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling", "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer --", "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer --", "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer --" diff --git a/tests/tooling/setup.ts b/tests/tooling/setup.ts new file mode 100644 index 000000000000..8283303f58c0 --- /dev/null +++ b/tests/tooling/setup.ts @@ -0,0 +1,7 @@ +// Preloaded via `bun test --preload` (see the `test:tooling` npm script). GitHub Actions always sets +// GITHUB_REPOSITORY in CI, but local runs need a default, mirroring jest/setup.ts's equivalent fallback for the +// test files Jest still owns. +if (!('GITHUB_REPOSITORY' in process.env)) { + process.env.GITHUB_REPOSITORY_OWNER = 'Expensify'; + process.env.GITHUB_REPOSITORY = 'Expensify/App'; +} diff --git a/tests/tooling/tsconfig.json b/tests/tooling/tsconfig.json new file mode 100644 index 000000000000..13d197938b38 --- /dev/null +++ b/tests/tooling/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + // bun:test's ambient types, which conflict with the `jest` types the root config uses everywhere else. + "types": ["@types/bun"] + }, + "include": ["./**/*.ts"], + // Reset the parent's `exclude`, which excludes this directory (see the root tsconfig.json) so these files + // aren't checked twice with the wrong `types`. + "exclude": ["../../**/node_modules/*"] +} diff --git a/tsconfig.json b/tsconfig.json index e9feecbbc06b..3e7670f2cd64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,5 +43,14 @@ "**/*.nitro/*.ts", "**/*.nitro/*.tsx" ], - "exclude": ["**/node_modules/*", "**/dist/*", ".github/actions/**/index.js", "**/docs/*", ".claude/worktrees/**"] + "exclude": [ + "**/node_modules/*", + "**/dist/*", + ".github/actions/**/index.js", + "**/docs/*", + ".claude/worktrees/**", + // These import `bun:test` and are type-checked by tests/tooling/tsconfig.json instead, which declares + // @types/bun — bun:test's ambient types aren't compatible with this config's `jest` types. + "tests/tooling/**" + ] } From f6be790c51f050a05a9a2290b6c4de75a5d4332a Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 11 Aug 2026 23:23:46 -0700 Subject: [PATCH 03/24] Port the .github tooling tests from Jest to bun:test These 14 files exercise .github/actions and .github/libs, whose @actions/@octokit dependencies go ESM-only in their next majors. Running them under bun:test means those imports resolve natively, so no CJS shims are needed when the upgrade lands. Mechanical changes throughout: import the test globals from bun:test, drop the `@jest-environment node` docblocks (Bun has no jsdom to opt out of), and swap Jest's two-parameter mock generics for Bun's single function-type form. Two differences needed real changes: - Bun resolves @actions/* as real ESM, whose namespace exports are read-only live bindings, so `core.getInput = mock` no longer works; these now use jest.spyOn. That also removes the tests' dependency on @src/types/utils/asMutable. - Bun has no `jest.mock(path)` automock and no advanceTimersByTimeAsync. Auto-mocked modules are replaced with explicit per-function spies, and DeployChecklistUtils' retry tests get a small local helper that yields to the microtask queue before firing the timer. postOrReplaceComment also drops jest-when, which depends on Jest's internal expect-matcher state. Its per-argument stubbing is replaced with a plain inputs record per test that throws on an undeclared input, preserving jest-when's strictness about unexpected calls. --- tests/tooling/CIGitLogic.test.ts | 21 +-- tests/tooling/DeployChecklistUtils.test.ts | 35 +++-- tests/tooling/GitUtils.test.ts | 2 + tests/tooling/GithubUtils.test.ts | 15 +- .../ReactCompilerComplianceCheck.test.ts | 2 + tests/tooling/awaitStagingDeploys.test.ts | 23 ++- tests/tooling/checkDeployBlockers.test.ts | 13 +- .../createOrUpdateDeployChecklist.test.ts | 49 ++++--- .../getPullRequestIncrementalChanges.test.ts | 31 ++-- tests/tooling/isAuthorizedContributor.test.ts | 4 +- tests/tooling/isDeployChecklistLocked.test.ts | 37 +++-- .../markPullRequestsAsDeployed.test.ts | 135 +++++++++--------- tests/tooling/postOrReplaceComment.test.ts | 117 +++++++-------- tests/tooling/waitForPreviousRuns.test.ts | 18 +-- 14 files changed, 257 insertions(+), 245 deletions(-) diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index f11709d3e19b..01e1d0a726e0 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -5,11 +5,8 @@ import GitUtils from '@github/libs/GitUtils'; import * as VersionUpdater from '@github/libs/versionUpdater'; import type {SemverLevel} from '@github/libs/versionUpdater'; -/** - * @jest-environment node - * @jest-config bail=true - */ import * as core from '@actions/core'; +import {afterAll, beforeAll, describe, expect, jest, setDefaultTimeout, test} from 'bun:test'; import {execSync} from 'child_process'; import fs from 'fs'; import os from 'os'; @@ -22,10 +19,10 @@ const DUMMY_DIR = path.resolve(os.homedir(), 'DumDumRepo'); const GIT_REMOTE = path.resolve(os.homedir(), 'dummyGitRemotes/DumDumRepo'); // Used to mock the Octokit GithubAPI -const mockGetInput = jest.fn(); +const mockGetInput = jest.fn<(name: string) => string | undefined>(); type CompareCommitsCommit = NonNullable>['data']['commits']>[number]; -const isVerbose = process.env.JEST_VERBOSE === 'true'; +const isVerbose = process.env.VERBOSE === 'true'; function exec(command: string) { try { @@ -72,8 +69,11 @@ function initGithubAPIMocking() { return mockGetInput(name) ?? ''; }); - // Mock various compareCommits responses with single mocked function - jest.spyOn(GithubUtils.octokit.repos, 'compareCommits').mockImplementation((params) => { + // Mock various compareCommits responses with a single mocked function. Assigned directly rather than via + // jest.spyOn/spyOn: Octokit's REST endpoint methods are lazily memoized (each is replaced with a plain value + // the first time it's accessed), and under bun:test, spyOn silently fails to override that already-memoized + // property on this particular object shape, so the mock is never installed. A direct assignment works fine. + const mockCompareCommits = jest.fn().mockImplementation((params: Parameters[0]) => { const base = params?.base; const head = params?.head; const tagPairKey = `${base}...${head}`; @@ -183,6 +183,7 @@ function initGithubAPIMocking() { }), ); }); + GithubUtils.octokit.repos.compareCommits = mockCompareCommits as unknown as typeof GithubUtils.octokit.repos.compareCommits; } function initGitServer() { @@ -468,6 +469,10 @@ async function assertPRsMergedBetween(from: string, to: string, expected: number * - The whole suite should be run. Running individual tests from the suite may not work as expected. */ +// These tests shell out to real `git`/`npm` subprocesses many times per test and can exceed the default 5000ms +// per-test timeout (shared with Jest's default), especially on a cold cache. +setDefaultTimeout(30000); + let startingDir: string; describe('CIGitLogic', () => { beforeAll(() => { diff --git a/tests/tooling/DeployChecklistUtils.test.ts b/tests/tooling/DeployChecklistUtils.test.ts index b645d39a3fa8..24fea51ebcea 100644 --- a/tests/tooling/DeployChecklistUtils.test.ts +++ b/tests/tooling/DeployChecklistUtils.test.ts @@ -3,11 +3,11 @@ import {generateDeployChecklistBodyAndAssignees, getDeployChecklist, NoOpenDeplo import type {InternalOctokit, ListForRepoMethod, OctokitIssueItem} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; -/** - * @jest-environment node - */ +import type {Mock} from 'bun:test'; + /* eslint-disable @typescript-eslint/naming-convention */ import {RequestError} from '@octokit/request-error'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; import createMock from '../utils/createMock'; @@ -19,10 +19,23 @@ type GetPullRequestResponse = Awaited>; const createListForRepoResponse = (data: OctokitIssueItem[]): ListForRepoResponse => createMock({data}); -const mockListIssues = jest.fn, Parameters>(); -let listForRepoSpy: jest.SpiedFunction; +const mockListIssues = jest.fn(); +let listForRepoSpy: Mock; let internalOctokit: InternalOctokit; +/** + * Bun's fake timers only expose a synchronous `jest.advanceTimersByTime`. The code under test schedules each + * backoff timer inside a `catch` block, i.e. several microtasks after the call under test starts, so yield until + * that timer exists before firing it, then yield again to let the continuation run. + */ +async function advanceTimersByTimeAsync(ms: number) { + for (let i = 0; jest.getTimerCount() === 0 && i < 100; i++) { + await Promise.resolve(); + } + jest.advanceTimersByTime(ms); + await Promise.resolve(); +} + beforeAll(() => { GithubUtils.initOctokitWithToken('fake_token'); const initializedOctokit = GithubUtils.internalOctokit; @@ -255,7 +268,7 @@ describe('DeployChecklistUtils', () => { jest.useFakeTimers(); try { const pending = getDeployChecklist(); - await jest.advanceTimersByTimeAsync(2000); + await advanceTimersByTimeAsync(2000); const data = await pending; expect(data.number).toBe(88); @@ -275,8 +288,8 @@ describe('DeployChecklistUtils', () => { try { const pending = getDeployChecklist(); const assertion = expect(pending).rejects.toThrow(RequestError); - await jest.advanceTimersByTimeAsync(2000); - await jest.advanceTimersByTimeAsync(5000); + await advanceTimersByTimeAsync(2000); + await advanceTimersByTimeAsync(5000); await assertion; expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(3); @@ -316,7 +329,7 @@ describe('DeployChecklistUtils', () => { jest.useFakeTimers(); try { const pending = getDeployChecklist(); - await jest.advanceTimersByTimeAsync(2000); + await advanceTimersByTimeAsync(2000); const data = await pending; expect(data.number).toBe(77); @@ -360,8 +373,8 @@ describe('DeployChecklistUtils', () => { createMock({number: 6, title: '[Internal QA] Another Test Internal QA PR', labels: [{name: 'InternalQA'}]}), createMock({number: 7, title: '[Internal QA] Another Test Internal QA PR', labels: [{name: 'InternalQA'}]}), ]; - let paginateSpy: jest.SpiedFunction; - let getPullRequestSpy: jest.SpiedFunction; + let paginateSpy: Mock; + let getPullRequestSpy: Mock; beforeAll(() => { paginateSpy = jest.spyOn(internalOctokit, 'paginate'); diff --git a/tests/tooling/GitUtils.test.ts b/tests/tooling/GitUtils.test.ts index 9de2b3182ca1..a87fe42bafcb 100644 --- a/tests/tooling/GitUtils.test.ts +++ b/tests/tooling/GitUtils.test.ts @@ -1,3 +1,5 @@ +import {describe, expect, test} from 'bun:test'; + import type {CommitType, MergedPR, SubmoduleUpdate} from '../../.github/libs/GitUtils'; import GitUtils from '../../.github/libs/GitUtils'; diff --git a/tests/tooling/GithubUtils.test.ts b/tests/tooling/GithubUtils.test.ts index 5aee158acef4..1d3b716e6b54 100644 --- a/tests/tooling/GithubUtils.test.ts +++ b/tests/tooling/GithubUtils.test.ts @@ -2,14 +2,10 @@ import CONST from '@github/libs/CONST'; import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; -import type {Writable} from 'type-fest'; - -/** - * @jest-environment node - */ /* eslint-disable @typescript-eslint/naming-convention */ import * as core from '@actions/core'; import {RequestError} from '@octokit/request-error'; +import {afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; const mockGetInput = jest.fn(); const mockListIssues = jest.fn(); @@ -20,11 +16,10 @@ type ObjectMethodData = { type OctokitCreateIssue = InternalOctokit['rest']['issues']['create']; -const asMutable = (value: T): Writable => value as Writable; - beforeAll(() => { - // Mock core module - asMutable(core).getInput = mockGetInput; + // Mock core module. Real ESM module namespace exports are read-only live bindings, so `core.getInput` can't be + // reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. + jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); // Mock octokit module const mockOctokit = { @@ -280,7 +275,7 @@ describe('GithubUtils', () => { }); test('should handle 404 RequestError with specific error message', async () => { - const coreErrorSpy = jest.spyOn(core, 'error').mockImplementation(); + const coreErrorSpy = jest.spyOn(core, 'error').mockImplementation(() => {}); const requestError = new RequestError('Not Found', 404, { request: { method: 'GET', diff --git a/tests/tooling/ReactCompilerComplianceCheck.test.ts b/tests/tooling/ReactCompilerComplianceCheck.test.ts index 23704bda275a..f00e99968e50 100644 --- a/tests/tooling/ReactCompilerComplianceCheck.test.ts +++ b/tests/tooling/ReactCompilerComplianceCheck.test.ts @@ -1,3 +1,5 @@ +import {describe, expect, it} from 'bun:test'; + import {checkReactCompilerCompliance} from '../../scripts/react-compiler-compliance-check'; describe('checkReactCompilerCompliance', () => { diff --git a/tests/tooling/awaitStagingDeploys.test.ts b/tests/tooling/awaitStagingDeploys.test.ts index 6363525b44a0..302d8aea41d7 100644 --- a/tests/tooling/awaitStagingDeploys.test.ts +++ b/tests/tooling/awaitStagingDeploys.test.ts @@ -1,15 +1,13 @@ import run from '@github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys'; -import type CONST from '@github/libs/CONST'; +import CONST from '@github/libs/CONST'; import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; -import asMutable from '@src/types/utils/asMutable'; +import type {Mock} from 'bun:test'; /* eslint-disable @typescript-eslint/naming-convention */ -/** - * @jest-environment node - */ import * as core from '@actions/core'; +import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; type Workflow = { workflow_id: string; @@ -30,7 +28,7 @@ type MockListResponse = { }; }; -type MockedFunctionListResponse = jest.MockedFunction<() => Promise>; +type MockedFunctionListResponse = Mock<() => Promise>; const consoleSpy = jest.spyOn(console, 'log'); const mockGetInput = jest.fn(); @@ -59,14 +57,15 @@ const mockListWorkflowRuns = jest.fn().mockImplementation((args: Workflow) => { return defaultReturn; }); -jest.mock('@github/libs/CONST', () => ({ - ...jest.requireActual('@github/libs/CONST'), - POLL_RATE: TEST_POLL_RATE, -})); +// CONST's default export is a plain mutable object shared by reference with every other importer (unlike named +// exports on a module namespace, which are read-only live bindings), so it can be overridden in place rather than +// needing mock.module. Lower the poll rate to speed up the test. +(CONST as {POLL_RATE: number}).POLL_RATE = TEST_POLL_RATE; beforeAll(() => { - // Mock core module - asMutable(core).getInput = mockGetInput; + // Mock core module. Real ESM module namespace exports are read-only live bindings, so `core.getInput` can't be + // reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. + jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); // Mock octokit module const mockOctokit = { diff --git a/tests/tooling/checkDeployBlockers.test.ts b/tests/tooling/checkDeployBlockers.test.ts index daeb95459183..0523edaa3434 100644 --- a/tests/tooling/checkDeployBlockers.test.ts +++ b/tests/tooling/checkDeployBlockers.test.ts @@ -2,12 +2,8 @@ import run from '@github/actions/javascript/checkDeployBlockers/checkDeployBlock import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; -import asMutable from '@src/types/utils/asMutable'; - -/** - * @jest-environment node - */ import * as core from '@actions/core'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; type CommentData = {body: string}; @@ -31,9 +27,10 @@ const mockGetIssue = jest.fn(); const mockListComments = jest.fn(); beforeAll(() => { - // Mock core module - asMutable(core).getInput = mockGetInput; - asMutable(core).setOutput = mockSetOutput; + // Mock core module. Real ESM module namespace exports are read-only live bindings, so `core.getInput` can't be + // reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. + jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); + jest.spyOn(core, 'setOutput').mockImplementation(mockSetOutput); // Mock octokit module const mockOctokit = { diff --git a/tests/tooling/createOrUpdateDeployChecklist.test.ts b/tests/tooling/createOrUpdateDeployChecklist.test.ts index cdbd27c129f8..20be1b4aac91 100644 --- a/tests/tooling/createOrUpdateDeployChecklist.test.ts +++ b/tests/tooling/createOrUpdateDeployChecklist.test.ts @@ -4,32 +4,25 @@ import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; import GitUtils from '@github/libs/GitUtils'; -import run from '@scripts/createOrUpdateDeployChecklist'; +import type {Mock} from 'bun:test'; import * as core from '@actions/core'; +import {afterAll, afterEach, beforeAll, describe, expect, jest, mock, test} from 'bun:test'; import * as fns from 'date-fns'; -import {vol} from 'memfs'; +import {fs as memfsFs, vol} from 'memfs'; import path from 'path'; -/** - * @jest-environment node - */ - /* eslint-disable @typescript-eslint/naming-convention */ -// Mock fs -jest.mock('fs'); +// Must run before `createOrUpdateDeployChecklist` (which imports `fs` internally) is imported below: mock.module +// patches the shared module registry entry, and existing import bindings are live, but only if the patch happens +// before those bindings are first read. +await mock.module('fs', () => ({...memfsFs, default: memfsFs})); -// Mock @actions/core for input handling and logging in tests -jest.mock('@actions/core', () => ({ - getInput: jest.fn(), - info: jest.fn(), - startGroup: jest.fn(), - endGroup: jest.fn(), - setFailed: jest.fn(), -})); +// Must be imported after the mock.module() call above so it picks up the mock. +const {default: run} = await import('@scripts/createOrUpdateDeployChecklist'); -const mockGetInput = core.getInput as jest.MockedFunction; +const mockGetInput = jest.fn(); type Arguments = { issue_number?: number; @@ -39,7 +32,7 @@ type Arguments = { const PATH_TO_PACKAGE_JSON = path.resolve(__dirname, '../../package.json'); const mockListIssues = jest.fn(); -const mockGetMergedPRsDeployedBetween = jest.fn() as jest.MockedFunction; +const mockGetMergedPRsDeployedBetween = jest.fn() as Mock; const mockGetWorkflowRunURLForCommit = jest.fn().mockResolvedValue(undefined); beforeAll(() => { @@ -80,6 +73,16 @@ beforeAll(() => { // Mock GitUtils GitUtils.getMergedPRsDeployedBetween = mockGetMergedPRsDeployedBetween; GithubUtils.getWorkflowRunURLForCommit = mockGetWorkflowRunURLForCommit; + + // Mock @actions/core for input handling and logging in tests. Real ESM module namespace exports are read-only + // live bindings, so these can't be reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on + // them instead. + jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); + jest.spyOn(core, 'info').mockImplementation(() => {}); + jest.spyOn(core, 'startGroup').mockImplementation(() => {}); + jest.spyOn(core, 'endGroup').mockImplementation(() => {}); + jest.spyOn(core, 'setFailed').mockImplementation(() => {}); + mockGetInput.mockImplementation((arg) => (arg === 'GITHUB_TOKEN' ? 'fake_token' : '')); vol.reset(); @@ -264,7 +267,7 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result).toStrictEqual({ + expect(result as unknown).toStrictEqual({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, title: `Deploy Checklist: New Expensify ${fns.format(new Date(), 'yyyy-MM-dd')}`, @@ -315,7 +318,7 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result).toStrictEqual({ + expect(result as unknown).toStrictEqual({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, title: `Deploy Checklist: New Expensify ${fns.format(new Date(), 'yyyy-MM-dd')}`, @@ -429,7 +432,7 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result).toStrictEqual({ + expect(result as unknown).toStrictEqual({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, issue_number: openDeployChecklistBefore.number, @@ -509,7 +512,7 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result).toStrictEqual({ + expect(result as unknown).toStrictEqual({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, issue_number: openDeployChecklistBefore.number, @@ -569,7 +572,7 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result).toStrictEqual({ + expect(result as unknown).toStrictEqual({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, issue_number: openDeployChecklistBefore.number, diff --git a/tests/tooling/getPullRequestIncrementalChanges.test.ts b/tests/tooling/getPullRequestIncrementalChanges.test.ts index 0d06edf57151..11ad6517b740 100644 --- a/tests/tooling/getPullRequestIncrementalChanges.test.ts +++ b/tests/tooling/getPullRequestIncrementalChanges.test.ts @@ -4,13 +4,11 @@ import type {InternalOctokit} from '@github/libs/GithubUtils'; import Git from '@scripts/utils/Git'; -import type {Writable} from 'type-fest'; +import type {Mock} from 'bun:test'; -/** - * @jest-environment node - */ import * as core from '@actions/core'; import {context} from '@actions/github'; +import {beforeAll, beforeEach, describe, expect, it, jest} from 'bun:test'; import createMock from '../utils/createMock'; @@ -19,20 +17,21 @@ type ListFilesResponse = Awaited>; type PaginateMethod = InternalOctokit['paginate']; let internalOctokit: InternalOctokit; -let paginateSpy: jest.SpiedFunction; +let paginateSpy: Mock; -// Mock all dependencies -jest.mock('@actions/core'); -jest.mock('@actions/github'); -jest.mock('@scripts/utils/Git'); +const mockGetInput = jest.fn(); -const mockSetOutput = jest.mocked(core.setOutput); -const mockGetInput = jest.fn(); +// Bun has no equivalent of `jest.mock(path)`'s automock, so stub the @actions/core functions this action calls +// explicitly. `@actions/github`'s `context` needs no stub: it is a plain mutable object whose constructor is a +// no-op without GITHUB_EVENT_PATH set, so each test can assign to it directly (see beforeEach below). +jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); +const mockSetOutput = jest.spyOn(core, 'setOutput').mockImplementation(() => {}); +jest.spyOn(core, 'warning').mockImplementation(() => {}); +jest.spyOn(core, 'startGroup').mockImplementation(() => {}); +jest.spyOn(core, 'endGroup').mockImplementation(() => {}); +jest.spyOn(core, 'setFailed').mockImplementation(() => {}); -// Mock @actions/core getInput -(core as Writable).getInput = mockGetInput; - -// Mock Git methods +// Mock Git methods. `Git`'s default export is a plain mutable object, so its methods can be overridden in place. const mockGitEnsureRef = jest.fn(); const mockGitDiff = jest.fn(); const mockGitParseDiff = jest.fn(); @@ -42,7 +41,7 @@ Git.diff = mockGitDiff; Git.parseDiff = mockGitParseDiff; // Mock GitHubUtils methods -const mockGetPullRequestDiff = jest.fn, Parameters>(); +const mockGetPullRequestDiff = jest.fn(); beforeAll(() => { GitHubUtils.initOctokitWithToken('fake_token'); diff --git a/tests/tooling/isAuthorizedContributor.test.ts b/tests/tooling/isAuthorizedContributor.test.ts index 17e5a6f3cd8f..b35599325bd7 100644 --- a/tests/tooling/isAuthorizedContributor.test.ts +++ b/tests/tooling/isAuthorizedContributor.test.ts @@ -1,7 +1,5 @@ -/** - * @jest-environment node - */ import {RequestError} from '@octokit/request-error'; +import {afterEach, beforeEach, describe, expect, jest, test} from 'bun:test'; import type {InternalOctokit} from '../../.github/libs/GithubUtils'; diff --git a/tests/tooling/isDeployChecklistLocked.test.ts b/tests/tooling/isDeployChecklistLocked.test.ts index 70c0e1d853bc..f06ab1cb5fdb 100644 --- a/tests/tooling/isDeployChecklistLocked.test.ts +++ b/tests/tooling/isDeployChecklistLocked.test.ts @@ -1,21 +1,27 @@ -/** - * @jest-environment node - */ import * as core from '@actions/core'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, mock, test} from 'bun:test'; -import run from '../../.github/actions/javascript/isDeployChecklistLocked/isDeployChecklistLocked'; import CONST from '../../.github/libs/CONST'; import * as DeployChecklistUtils from '../../.github/libs/DeployChecklistUtils'; -jest.mock('../../.github/libs/DeployChecklistUtils', () => { - const actual = jest.requireActual('../../.github/libs/DeployChecklistUtils') as unknown as typeof DeployChecklistUtils; - return { - ...actual, - getDeployChecklist: jest.fn(), - }; -}); +const mockGetDeployChecklist = jest.fn(); + +// Capture the real exports by value before mocking: `DeployChecklistUtils` is a live namespace binding tied to +// the shared module registry entry, so once mock.module() below replaces that entry, `DeployChecklistUtils` would +// itself resolve to the mocked exports too - which would make the afterAll restoration below a no-op that +// "restores" the mock forever instead of the real module. +const originalDeployChecklistUtils = {...DeployChecklistUtils}; + +// Must run before `isDeployChecklistLocked` (which imports DeployChecklistUtils internally) is imported below: +// mock.module patches the shared module registry entry, and existing named-import bindings to it are live, but +// only if the patch happens before those bindings are first read. +await mock.module('../../.github/libs/DeployChecklistUtils', () => ({ + ...DeployChecklistUtils, + getDeployChecklist: mockGetDeployChecklist, +})); -const mockGetDeployChecklist = DeployChecklistUtils.getDeployChecklist as jest.MockedFunction; +// Must be imported after the mock.module() call above so it picks up the mock. +const {default: run} = await import('../../.github/actions/javascript/isDeployChecklistLocked/isDeployChecklistLocked'); beforeAll(() => { process.env.INPUT_GITHUB_TOKEN = 'fake_token'; @@ -29,8 +35,11 @@ afterEach(() => { jest.restoreAllMocks(); }); -afterAll(() => { +afterAll(async () => { delete process.env.INPUT_GITHUB_TOKEN; + // `bun test` runs all files in one process sharing the module registry, unlike Jest's per-file registry, so + // mock.module's patch would otherwise leak into every other test file that imports this module afterwards. + await mock.module('../../.github/libs/DeployChecklistUtils', () => originalDeployChecklistUtils); }); describe('isDeployChecklistLockedTest', () => { @@ -80,7 +89,7 @@ describe('isDeployChecklistLockedTest', () => { const setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation(() => {}); return run().then(() => { expect(setFailedMock).toHaveBeenCalledTimes(1); - expect(setFailedMock.mock.calls.at(0)?.at(0)).toEqual(expect.stringContaining('Could not resolve deploy checklist')); + expect(setFailedMock.mock.calls.at(0)?.at(0)).toContain('Could not resolve deploy checklist'); expect(setOutputMock).not.toHaveBeenCalledWith('IS_LOCKED', expect.anything()); expect(setOutputMock).not.toHaveBeenCalledWith('NUMBER', expect.anything()); }); diff --git a/tests/tooling/markPullRequestsAsDeployed.test.ts b/tests/tooling/markPullRequestsAsDeployed.test.ts index 8b18c7f5ea29..07f2ae88957a 100644 --- a/tests/tooling/markPullRequestsAsDeployed.test.ts +++ b/tests/tooling/markPullRequestsAsDeployed.test.ts @@ -1,9 +1,10 @@ +import * as core from '@actions/core'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; + import type {InternalOctokit} from '../../.github/libs/GithubUtils'; -/** - * @jest-environment node - */ /* eslint-disable @typescript-eslint/naming-convention */ +import * as ActionUtils from '../../.github/libs/ActionUtils'; import CONST from '../../.github/libs/CONST'; import GithubUtils from '../../.github/libs/GithubUtils'; import GitUtils from '../../.github/libs/GitUtils'; @@ -37,15 +38,74 @@ type CommitData = { }; }; -let run: () => Promise; - const mockGetInput = jest.fn(); const mockGetPullRequest = jest.fn(); const mockCreateComment = jest.fn(); const mockListTags = jest.fn(); const mockGetCommit = jest.fn(); -let workflowRunURL: string | null; +const mockGetJSONInput = jest.fn().mockImplementation((name: string, defaultValue: string) => { + try { + const input = mockGetInput(name) as string; + return JSON.parse(input) as unknown; + } catch (err) { + return defaultValue; + } +}); + +// Must run before `markPullRequestsAsDeployed` (which imports `ActionUtils` internally) is imported below: +// mock.module patches the shared module registry entry, and existing named-import bindings to it are live, but +// only if the patch happens before those bindings are first read. +await mock.module('../../.github/libs/ActionUtils', () => ({ + ...ActionUtils, + getJSONInput: mockGetJSONInput, +})); + +// Must also be set before `markPullRequestsAsDeployed` is imported below: it computes `workflowURL` from these env +// vars in a top-level (module-load-time) constant, not at runtime. +process.env.GITHUB_SERVER_URL = 'https://github.com'; +process.env.GITHUB_RUN_ID = '1234'; +const workflowRunURL = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; + +// Mock core module. Real ESM module namespace exports are read-only live bindings, so `core.getInput` can't be +// reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. +jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); + +// Must also be set before `markPullRequestsAsDeployed` is imported below: it does `memoize(GithubUtils.octokit.git. +// getCommit)` in a top-level (module-load-time) constant, capturing whatever `internalOctokit` points to at that +// moment rather than reading it fresh on each call. +const mockOctokit = { + rest: { + issues: { + listForRepo: jest.fn().mockImplementation(async () => ({ + data: [ + { + number: 5, + }, + ], + })), + + listEvents: jest.fn().mockImplementation(async () => ({ + data: [{event: 'closed', actor: {login: 'thor'}}], + })), + createComment: mockCreateComment, + }, + pulls: { + get: mockGetPullRequest, + }, + repos: { + listTags: mockListTags, + }, + git: { + getCommit: mockGetCommit, + }, + }, + paginate: jest.fn().mockImplementation((objectMethod: () => Promise>) => objectMethod().then(({data}) => data)), +}; +GithubUtils.internalOctokit = mockOctokit as unknown as InternalOctokit; + +// Must be imported after the GithubUtils.internalOctokit setup above so it picks up the mocks. +const {default: run} = await import('../../.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed'); const PRList: Record = { 1: { @@ -101,62 +161,10 @@ function mockGetCommitDefaultImplementation({commit_sha}: Commit): CommitData { } beforeAll(() => { - // Mock core module - jest.mock('@actions/core', () => ({ - getInput: mockGetInput, - })); mockGetInput.mockImplementation(mockGetInputDefaultImplementation); - // Mock octokit module - const mockOctokit = { - rest: { - issues: { - listForRepo: jest.fn().mockImplementation(async () => ({ - data: [ - { - number: 5, - }, - ], - })), - - listEvents: jest.fn().mockImplementation(async () => ({ - data: [{event: 'closed', actor: {login: 'thor'}}], - })), - createComment: mockCreateComment, - }, - pulls: { - get: mockGetPullRequest, - }, - repos: { - listTags: mockListTags, - }, - git: { - getCommit: mockGetCommit, - }, - }, - paginate: jest.fn().mockImplementation((objectMethod: () => Promise>) => objectMethod().then(({data}) => data)), - }; - - GithubUtils.internalOctokit = mockOctokit as unknown as InternalOctokit; - // Mock GitUtils GitUtils.getPullRequestsDeployedBetween = jest.fn(); - - jest.mock('../../.github/libs/ActionUtils', () => ({ - getJSONInput: jest.fn().mockImplementation((name: string, defaultValue: string) => { - try { - const input = mockGetInput(name) as string; - return JSON.parse(input) as unknown; - } catch (err) { - return defaultValue; - } - }), - })); - - // Set GH runner environment variables - process.env.GITHUB_SERVER_URL = 'https://github.com'; - process.env.GITHUB_RUN_ID = '1234'; - workflowRunURL = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; }); beforeEach(() => { @@ -177,12 +185,8 @@ afterAll(() => { jest.clearAllMocks(); }); -type MockedActionRun = () => Promise; - describe('markPullRequestsAsDeployed', () => { it('comments on pull requests correctly for a standard staging deploy', async () => { - // Note: we import this in here so that it executes after all the mocks are set up - run = require('../../.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed'); await run(); expect(mockCreateComment).toHaveBeenCalledTimes(Object.keys(PRList).length); for (let i = 0; i < Object.keys(PRList).length; i++) { @@ -210,9 +214,6 @@ platform | result return mockGetInputDefaultImplementation(key); }); - // Note: we import this in here so that it executes after all the mocks are set up - run = require('../../.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed'); - await run(); expect(mockCreateComment).toHaveBeenCalledTimes(Object.keys(PRList).length); for (let i = 0; i < Object.keys(PRList).length; i++) { @@ -270,8 +271,6 @@ platform | result return mockGetCommitDefaultImplementation({commit_sha}); }); - // Note: we import this in here so that it executes after all the mocks are set up - run = require('../../.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed'); await run(); expect(mockCreateComment).toHaveBeenCalledTimes(1); expect(mockCreateComment).toHaveBeenCalledWith({ @@ -299,8 +298,6 @@ platform | result return mockGetInputDefaultImplementation(key); }); - // Note: we import this in here so that it executes after all the mocks are set up - run = require('../../.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed'); await run(); expect(mockCreateComment).toHaveBeenCalledTimes(Object.keys(PRList).length); for (let i = 0; i < Object.keys(PRList).length; i++) { diff --git a/tests/tooling/postOrReplaceComment.test.ts b/tests/tooling/postOrReplaceComment.test.ts index 8f37589de9a1..9c7c2f19efc1 100644 --- a/tests/tooling/postOrReplaceComment.test.ts +++ b/tests/tooling/postOrReplaceComment.test.ts @@ -2,19 +2,16 @@ import ghAction from '@github/actions/javascript/postOrReplaceComment/postOrRepl import CONST from '@github/libs/CONST'; import GithubUtils from '@github/libs/GithubUtils'; -import asMutable from '@src/types/utils/asMutable'; +import type {Mock} from 'bun:test'; -/** - * @jest-environment node - */ import * as core from '@actions/core'; import {context} from '@actions/github'; import * as GitHubEnvironment from '@actions/github/lib/utils'; -import {when} from 'jest-when'; +import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; import createMock from '../utils/createMock'; -const mockGetInput = jest.fn(); +const mockGetInput = jest.fn(); const createCommentMock = jest.spyOn(GithubUtils, 'createComment'); type InternalOctokit = NonNullable; type CreateCommentResponse = Awaited>; @@ -24,27 +21,26 @@ type ListCommentsEndpoint = ListCommentsMethod['endpoint']; type GraphqlMethod = InternalOctokit['graphql']; let internalOctokit: InternalOctokit; -let listCommentsSpy: jest.SpiedFunction; -let graphqlSpy: jest.SpiedFunction; +let listCommentsSpy: Mock; +let graphqlSpy: Mock; -jest.mock('@actions/github', () => { - const repository = process.env.GITHUB_REPOSITORY; - if (!repository) { - throw new Error('GITHUB_REPOSITORY must be set in owner/repository format.'); - } - - const [owner, repo, ...extraParts] = repository.split('/'); - if (!owner || !repo || extraParts.length > 0 || /\s/.test(owner) || /\s/.test(repo)) { - throw new Error(`GITHUB_REPOSITORY must be set in owner/repository format, received: ${repository}`); - } +// `context` is a plain object instance whose constructor is a no-op without GITHUB_EVENT_PATH set, so it can be +// mutated directly rather than mocking the module. `context.repo` reads GITHUB_REPOSITORY, which tests/tooling/setup.ts +// defaults to Expensify/App. +context.runId = 1234; - return { - context: { - repo: {owner, repo}, - runId: 1234, - }, - }; -}); +/** + * Stubs `core.getInput` for one test. Reading an input the test didn't declare throws rather than silently + * returning the empty string, so a new `getInput` call in the action can't quietly change what these tests assert. + */ +function mockInputs(inputs: Record) { + mockGetInput.mockImplementation((name: string) => { + if (!(name in inputs)) { + throw new Error(`Unexpected core.getInput('${name}'): add it to this test's inputs.`); + } + return inputs[name]; + }); +} const previousCommentsResponse = createMock({ data: [ @@ -163,8 +159,9 @@ Built from Mobile-Expensify PR Expensify/Mobile-Expensify#13. describe('postOrReplaceComment action tests', () => { beforeAll(() => { - // Mock core module - asMutable(core).getInput = mockGetInput; + // Real ESM module namespace exports are read-only live bindings, so `core.getInput` can't be reassigned + // directly the way Jest's Babel-transpiled CJS interop allowed; spy on it instead. + jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); }); beforeEach(() => { @@ -191,17 +188,19 @@ describe('postOrReplaceComment action tests', () => { } test('Test GH action', async () => { - when(core.getInput).calledWith('REPO', {required: true}).mockReturnValue(CONST.APP_REPO); - when(core.getInput).calledWith('APP_PR_NUMBER', {required: false}).mockReturnValue('12'); - when(core.getInput).calledWith('MOBILE_EXPENSIFY_PR_NUMBER', {required: false}).mockReturnValue('13'); - when(core.getInput).calledWith('COMMENT_PREFIX', {required: true}).mockReturnValue(testBuildCommentPrefix); - when(core.getInput).calledWith('COMMENT_BODY', {required: false}).mockReturnValue(''); - when(core.getInput).calledWith('ANDROID', {required: false}).mockReturnValue('success'); - when(core.getInput).calledWith('IOS', {required: false}).mockReturnValue('success'); - when(core.getInput).calledWith('WEB', {required: false}).mockReturnValue('success'); - when(core.getInput).calledWith('ANDROID_LINK').mockReturnValue(androidLink); - when(core.getInput).calledWith('IOS_LINK').mockReturnValue(iOSLink); - when(core.getInput).calledWith('WEB_LINK').mockReturnValue('https://expensify.app/WEB_LINK'); + mockInputs({ + REPO: CONST.APP_REPO, + APP_PR_NUMBER: '12', + MOBILE_EXPENSIFY_PR_NUMBER: '13', + COMMENT_PREFIX: testBuildCommentPrefix, + COMMENT_BODY: '', + ANDROID: 'success', + IOS: 'success', + WEB: 'success', + ANDROID_LINK: androidLink, + IOS_LINK: iOSLink, + WEB_LINK: webLink, + }); createCommentMock.mockResolvedValue(createMock({})); await ghAction(); expectPreviousCommentToBeHidden(); @@ -210,15 +209,17 @@ describe('postOrReplaceComment action tests', () => { }); test('Test GH action when only App PR number is provided', async () => { - when(core.getInput).calledWith('REPO', {required: true}).mockReturnValue(CONST.APP_REPO); - when(core.getInput).calledWith('APP_PR_NUMBER', {required: false}).mockReturnValue('12'); - when(core.getInput).calledWith('MOBILE_EXPENSIFY_PR_NUMBER', {required: false}).mockReturnValue(''); - when(core.getInput).calledWith('COMMENT_PREFIX', {required: true}).mockReturnValue(testBuildCommentPrefix); - when(core.getInput).calledWith('COMMENT_BODY', {required: false}).mockReturnValue(''); - when(core.getInput).calledWith('ANDROID', {required: false}).mockReturnValue('success'); - when(core.getInput).calledWith('IOS', {required: false}).mockReturnValue('skipped'); - when(core.getInput).calledWith('WEB', {required: false}).mockReturnValue('skipped'); - when(core.getInput).calledWith('ANDROID_LINK').mockReturnValue('https://expensify.app/ANDROID_LINK'); + mockInputs({ + REPO: CONST.APP_REPO, + APP_PR_NUMBER: '12', + MOBILE_EXPENSIFY_PR_NUMBER: '', + COMMENT_PREFIX: testBuildCommentPrefix, + COMMENT_BODY: '', + ANDROID: 'success', + IOS: 'skipped', + WEB: 'skipped', + ANDROID_LINK: androidLink, + }); createCommentMock.mockResolvedValue(createMock({})); await ghAction(); expectPreviousCommentToBeHidden(); @@ -227,16 +228,18 @@ describe('postOrReplaceComment action tests', () => { }); test('Test GH action when only Mobile-Expensify PR number is provided', async () => { - when(core.getInput).calledWith('REPO', {required: true}).mockReturnValue(CONST.MOBILE_EXPENSIFY_REPO); - when(core.getInput).calledWith('APP_PR_NUMBER', {required: false}).mockReturnValue(''); - when(core.getInput).calledWith('MOBILE_EXPENSIFY_PR_NUMBER', {required: false}).mockReturnValue('13'); - when(core.getInput).calledWith('COMMENT_PREFIX', {required: true}).mockReturnValue(testBuildCommentPrefix); - when(core.getInput).calledWith('COMMENT_BODY', {required: false}).mockReturnValue(''); - when(core.getInput).calledWith('ANDROID', {required: false}).mockReturnValue('success'); - when(core.getInput).calledWith('IOS', {required: false}).mockReturnValue('success'); - when(core.getInput).calledWith('ANDROID_LINK').mockReturnValue(androidLink); - when(core.getInput).calledWith('IOS_LINK').mockReturnValue(iOSLink); - when(core.getInput).calledWith('WEB', {required: false}).mockReturnValue('skipped'); + mockInputs({ + REPO: CONST.MOBILE_EXPENSIFY_REPO, + APP_PR_NUMBER: '', + MOBILE_EXPENSIFY_PR_NUMBER: '13', + COMMENT_PREFIX: testBuildCommentPrefix, + COMMENT_BODY: '', + ANDROID: 'success', + IOS: 'success', + WEB: 'skipped', + ANDROID_LINK: androidLink, + IOS_LINK: iOSLink, + }); createCommentMock.mockResolvedValue(createMock({})); await ghAction(); expectPreviousCommentToBeHidden(); diff --git a/tests/tooling/waitForPreviousRuns.test.ts b/tests/tooling/waitForPreviousRuns.test.ts index c3425e339076..a7d1b5780abc 100644 --- a/tests/tooling/waitForPreviousRuns.test.ts +++ b/tests/tooling/waitForPreviousRuns.test.ts @@ -2,13 +2,9 @@ import run from '@github/actions/javascript/waitForPreviousRuns/waitForPreviousR import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; -import asMutable from '@src/types/utils/asMutable'; - /* eslint-disable @typescript-eslint/naming-convention */ -/** - * @jest-environment node - */ import * as core from '@actions/core'; +import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; const CURRENT_RUN_ID = 1000; const WORKFLOW_ID = 'testBuildOnPush.yml'; @@ -51,16 +47,10 @@ function getErrorMessages(): string[] { return coreErrorSpy.mock.calls.map((call) => String(call[0])); } -jest.mock('@github/libs/CONST', () => ({ - __esModule: true, - default: { - GITHUB_OWNER: 'Expensify', - APP_REPO: 'App', - }, -})); - beforeAll(() => { - asMutable(core).getInput = mockGetInput; + // Real ESM module namespace exports are read-only live bindings, so `core.getInput` can't be reassigned + // directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. + jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); mockGetInput.mockImplementation((name: string) => { if (name === 'WORKFLOW_ID') { From b4a8a56f5f70b4aba2ecb7fe7d76bd7bf3b697bb Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 11 Aug 2026 23:42:36 -0700 Subject: [PATCH 04/24] Export markPullRequestsAsDeployed's action with ESM This is the only action still ending in `module.exports = run` while using ESM `import` statements at the top. Babel rewrote the whole file to CommonJS for Jest, so the mix went unnoticed; Bun loads it as real ESM and refuses it outright ("Expected CommonJS module to have a function wrapper"), which blocks migrating its test to bun:test. Every other action already uses `export default run`. The rebuilt bundle changes only the three corresponding lines, and its `require.main === require.cache[...]` entry guard - the only thing that actually invokes the action - is unaffected. --- .../actions/javascript/markPullRequestsAsDeployed/index.js | 6 +++--- .../markPullRequestsAsDeployed.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 2cd5e7203fff..f380b153cd90 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -12707,7 +12707,7 @@ function wrappy (fn, cb) { /***/ }), /***/ 2483: -/***/ (function(module, exports, __nccwpck_require__) { +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -12748,7 +12748,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -/* eslint-disable @typescript-eslint/naming-convention, import/no-import-module-exports */ +/* eslint-disable @typescript-eslint/naming-convention */ const ActionUtils = __importStar(__nccwpck_require__(6981)); const CONST_1 = __importDefault(__nccwpck_require__(9873)); const GithubUtils_1 = __importDefault(__nccwpck_require__(9296)); @@ -12937,7 +12937,7 @@ async function run() { if (require.main === require.cache[eval('__filename')]) { run(); } -module.exports = run; +exports["default"] = run; /***/ }), diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts index e0d07d1341ab..cf5b0baedd45 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts +++ b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention, import/no-import-module-exports */ +/* eslint-disable @typescript-eslint/naming-convention */ import * as ActionUtils from '@github/libs/ActionUtils'; import CONST from '@github/libs/CONST'; import GithubUtils from '@github/libs/GithubUtils'; @@ -219,4 +219,4 @@ if (require.main === module) { run(); } -module.exports = run; +export default run; From 335f5fa81e73b38fe6e93b6b48370b83fe7b0c5d Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 11 Aug 2026 23:42:38 -0700 Subject: [PATCH 05/24] Port the remaining scripts and libs tests to bun:test Completes tests/tooling/ with the five files the earlier commit left on Jest, so the whole directory now runs under one runner: - bumpVersion and Git relied on `jest.mock(path)`'s automock, which Bun has no equivalent of. They now replace `fs`/`fs/promises` and `child_process` with mock.module() before importing the module under test. --isolate keeps those replacements from reaching other files. - markPullRequestsAsDeployed drops its ActionUtils mock and exercises the real getJSONInput, since the mock only reimplemented it. That means core.getInput's stub has to return strings, as the real one does, and MOBILE_EXPENSIFY_PR_LIST has to be a declared input rather than one that throws and gets swallowed. - DeployChecklistUtils' retry tests replace advanceTimersByTimeAsync, which Bun lacks, with a helper that alternates between yielding to the microtask queue and advancing the clock until the call settles. It throws rather than hanging if the call is waiting on something else. - versionUpdater, failureNotifier and detectReactComponent needed only their bun:test imports. --- tests/tooling/DeployChecklistUtils.test.ts | 83 +++++++++---------- tests/tooling/Git.test.ts | 24 +++--- tests/tooling/bumpVersion.test.ts | 21 +++-- tests/tooling/detectReactComponent.test.ts | 2 + tests/tooling/failureNotifier.test.ts | 5 +- .../markPullRequestsAsDeployed.test.ts | 31 ++----- tests/tooling/versionUpdater.test.ts | 2 + 7 files changed, 81 insertions(+), 87 deletions(-) diff --git a/tests/tooling/DeployChecklistUtils.test.ts b/tests/tooling/DeployChecklistUtils.test.ts index 24fea51ebcea..34a8c79f2208 100644 --- a/tests/tooling/DeployChecklistUtils.test.ts +++ b/tests/tooling/DeployChecklistUtils.test.ts @@ -24,16 +24,36 @@ let listForRepoSpy: Mock; let internalOctokit: InternalOctokit; /** - * Bun's fake timers only expose a synchronous `jest.advanceTimersByTime`. The code under test schedules each - * backoff timer inside a `catch` block, i.e. several microtasks after the call under test starts, so yield until - * that timer exists before firing it, then yield again to let the continuation run. + * Runs `operation` with fake timers so its retry backoff resolves instantly. Bun only exposes a synchronous + * `jest.advanceTimersByTime`, and the code under test schedules each backoff timer from a `catch` block - i.e. only + * once the preceding rejection has been awaited - so alternate between yielding to the microtask queue and pushing + * the clock forward until the operation settles. */ -async function advanceTimersByTimeAsync(ms: number) { - for (let i = 0; jest.getTimerCount() === 0 && i < 100; i++) { - await Promise.resolve(); - } - jest.advanceTimersByTime(ms); - await Promise.resolve(); +function runWithFakeTimers(operation: () => Promise): Promise { + jest.useFakeTimers(); + let isSettled = false; + const pending = operation().finally(() => { + isSettled = true; + }); + + // The caller decides whether a rejection is expected; swallow it here only so driving the clock below doesn't + // trip Bun's unhandled-rejection reporting in the meantime. + pending.catch(() => {}); + + return (async () => { + try { + for (let i = 0; !isSettled && i < 1000; i++) { + await Promise.resolve(); + jest.advanceTimersByTime(1000); + } + if (!isSettled) { + throw new Error('Operation did not settle after advancing fake timers; is it waiting on something other than a timer?'); + } + return await pending; + } finally { + jest.useRealTimers(); + } + })(); } beforeAll(() => { @@ -265,17 +285,10 @@ describe('DeployChecklistUtils', () => { createListForRepoResponse([createMock({number: 88, url: 'https://api.github.com/repos/o/i/issues/88', title: 't', labels: [], body: ''})]), ); - jest.useFakeTimers(); - try { - const pending = getDeployChecklist(); - await advanceTimersByTimeAsync(2000); - const data = await pending; - - expect(data.number).toBe(88); - expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(2); - } finally { - jest.useRealTimers(); - } + const data = await runWithFakeTimers(() => getDeployChecklist()); + + expect(data.number).toBe(88); + expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(2); }); test('re-throws after all retry attempts fail', async () => { @@ -284,18 +297,9 @@ describe('DeployChecklistUtils', () => { }); mockListIssues.mockRejectedValue(err503); - jest.useFakeTimers(); - try { - const pending = getDeployChecklist(); - const assertion = expect(pending).rejects.toThrow(RequestError); - await advanceTimersByTimeAsync(2000); - await advanceTimersByTimeAsync(5000); - await assertion; - - expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(3); - } finally { - jest.useRealTimers(); - } + await expect(runWithFakeTimers(() => getDeployChecklist())).rejects.toThrow(RequestError); + + expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(3); }); test('does not retry on empty result; falls through to state:all cross-check', async () => { @@ -326,17 +330,10 @@ describe('DeployChecklistUtils', () => { createListForRepoResponse([createMock({number: 77, url: 'https://api.github.com/repos/o/i/issues/77', title: 't', labels: [], body: ''})]), ); - jest.useFakeTimers(); - try { - const pending = getDeployChecklist(); - await advanceTimersByTimeAsync(2000); - const data = await pending; - - expect(data.number).toBe(77); - expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(2); - } finally { - jest.useRealTimers(); - } + const data = await runWithFakeTimers(() => getDeployChecklist()); + + expect(data.number).toBe(77); + expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(2); }); test('state:all reports a non-first open issue → fails closed with that number', async () => { diff --git a/tests/tooling/Git.test.ts b/tests/tooling/Git.test.ts index 0f8167be4a7a..ff8f50dbb9af 100644 --- a/tests/tooling/Git.test.ts +++ b/tests/tooling/Git.test.ts @@ -1,17 +1,21 @@ -import Git from '@scripts/utils/Git'; +import type {Mock} from 'bun:test'; -/** - * @jest-environment node - */ -import {execSync} from 'child_process'; +import {afterEach, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; +import * as childProcess from 'child_process'; import {Str} from 'expensify-common'; import fs from 'fs'; import createMock from '../utils/createMock'; -// Mock execSync to control git diff output -jest.mock('child_process'); -const mockExecSync = jest.mocked(execSync); +// Mock execSync to control git diff output. Bun has no equivalent of `jest.mock(path)`'s automock, and +// `child_process`'s named exports are read-only live bindings, so replace the whole module. This must run before +// `Git` (which imports execSync internally) is imported below, and `bun test --isolate` keeps the replacement from +// leaking into the other files in tests/tooling. +const mockExecSync = jest.fn(); +await mock.module('child_process', () => ({...childProcess, execSync: mockExecSync})); + +// Must be imported after the mock.module() call above so it picks up the mock. +const {default: Git} = await import('@scripts/utils/Git'); // Test constants for untracked files tests const MOCK_COMPONENT_CONTENT = 'const Component = () => null;\n'; @@ -1223,8 +1227,8 @@ describe('Git', () => { }); describe('diff with shouldIncludeUntrackedFiles', () => { - let mockExistsSync: jest.SpyInstance; - let mockReadFileSync: jest.SpyInstance; + let mockExistsSync: Mock; + let mockReadFileSync: Mock; beforeEach(() => { jest.clearAllMocks(); diff --git a/tests/tooling/bumpVersion.test.ts b/tests/tooling/bumpVersion.test.ts index b218135a6502..6417a01be95b 100644 --- a/tests/tooling/bumpVersion.test.ts +++ b/tests/tooling/bumpVersion.test.ts @@ -1,8 +1,16 @@ -import fs from 'fs'; -import {vol} from 'memfs'; +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {fs as memfsFs, vol} from 'memfs'; import path from 'path'; -import {generateAndroidVersionCode, updateAndroid} from '../../scripts/bumpVersion'; +// Must run before `bumpVersion` (which imports `fs` and `fs/promises` internally) is imported below: mock.module +// patches the shared module registry entry, and existing import bindings are live, but only if the patch happens +// before those bindings are first read. `bun test --isolate` gives each test file its own registry, so this does +// not leak into the other files in tests/tooling. +await mock.module('fs', () => ({...memfsFs, default: memfsFs})); +await mock.module('fs/promises', () => ({...memfsFs.promises, default: memfsFs.promises})); + +// Must be imported after the mock.module() calls above so it picks up the mocked filesystem. +const {generateAndroidVersionCode, updateAndroid} = await import('../../scripts/bumpVersion'); const BUILD_GRADLE_PATH = path.resolve(__dirname, '../../android/app/build.gradle'); const ANDROID_MANIFEST_PATH = path.resolve(__dirname, '../../Mobile-Expensify/Android/AndroidManifest.xml'); @@ -23,9 +31,6 @@ const mockAndroidManifest = ` xmlns:tools="http://schemas.android.com/tools"> `; -jest.mock('fs'); -jest.mock('fs/promises'); - beforeEach(() => { // Clear the mocked filesystem vol.reset(); @@ -108,9 +113,9 @@ describe('BumpVersion', () => { ], ])('updateAndroid("%s")', async (versionName, expectedBuildGradle, expectedAndroidManifest) => { await updateAndroid(versionName); - const buildGradle = fs.readFileSync(BUILD_GRADLE_PATH, {encoding: 'utf8'}); + const buildGradle = memfsFs.readFileSync(BUILD_GRADLE_PATH, {encoding: 'utf8'}); expect(buildGradle).toBe(expectedBuildGradle); - const androidManifest = fs.readFileSync(ANDROID_MANIFEST_PATH, {encoding: 'utf8'}); + const androidManifest = memfsFs.readFileSync(ANDROID_MANIFEST_PATH, {encoding: 'utf8'}); expect(androidManifest).toBe(expectedAndroidManifest); }); }); diff --git a/tests/tooling/detectReactComponent.test.ts b/tests/tooling/detectReactComponent.test.ts index 355aab843482..5df93baad9b9 100644 --- a/tests/tooling/detectReactComponent.test.ts +++ b/tests/tooling/detectReactComponent.test.ts @@ -1,3 +1,5 @@ +import {describe, expect, it} from 'bun:test'; + import {detectReactComponent} from '../../.github/actions/javascript/authorChecklist/categories/newComponentCategory'; describe('detectReactComponent test', () => { diff --git a/tests/tooling/failureNotifier.test.ts b/tests/tooling/failureNotifier.test.ts index e1961a073f00..9074e9f73171 100644 --- a/tests/tooling/failureNotifier.test.ts +++ b/tests/tooling/failureNotifier.test.ts @@ -1,10 +1,9 @@ -/** - * @jest-environment node - */ /* eslint-disable @typescript-eslint/naming-convention -- matching GitHub API response field names */ import {getMergedPR} from '@github/actions/javascript/failureNotifier/failureNotifier'; import type {PullRequest} from '@github/actions/javascript/failureNotifier/failureNotifier'; +import {describe, expect, it} from 'bun:test'; + describe('getMergedPR', () => { const mergedPR = { html_url: 'https://github.com/Expensify/App/pull/82016', diff --git a/tests/tooling/markPullRequestsAsDeployed.test.ts b/tests/tooling/markPullRequestsAsDeployed.test.ts index 07f2ae88957a..80f4eaedd089 100644 --- a/tests/tooling/markPullRequestsAsDeployed.test.ts +++ b/tests/tooling/markPullRequestsAsDeployed.test.ts @@ -1,10 +1,9 @@ import * as core from '@actions/core'; -import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest} from 'bun:test'; import type {InternalOctokit} from '../../.github/libs/GithubUtils'; /* eslint-disable @typescript-eslint/naming-convention */ -import * as ActionUtils from '../../.github/libs/ActionUtils'; import CONST from '../../.github/libs/CONST'; import GithubUtils from '../../.github/libs/GithubUtils'; import GitUtils from '../../.github/libs/GitUtils'; @@ -44,24 +43,7 @@ const mockCreateComment = jest.fn(); const mockListTags = jest.fn(); const mockGetCommit = jest.fn(); -const mockGetJSONInput = jest.fn().mockImplementation((name: string, defaultValue: string) => { - try { - const input = mockGetInput(name) as string; - return JSON.parse(input) as unknown; - } catch (err) { - return defaultValue; - } -}); - -// Must run before `markPullRequestsAsDeployed` (which imports `ActionUtils` internally) is imported below: -// mock.module patches the shared module registry entry, and existing named-import bindings to it are live, but -// only if the patch happens before those bindings are first read. -await mock.module('../../.github/libs/ActionUtils', () => ({ - ...ActionUtils, - getJSONInput: mockGetJSONInput, -})); - -// Must also be set before `markPullRequestsAsDeployed` is imported below: it computes `workflowURL` from these env +// Must be set before `markPullRequestsAsDeployed` is imported below: it computes `workflowURL` from these env // vars in a top-level (module-load-time) constant, not at runtime. process.env.GITHUB_SERVER_URL = 'https://github.com'; process.env.GITHUB_RUN_ID = '1234'; @@ -131,12 +113,14 @@ const defaultTags = [ {name: '42.42.42-41', commit: {sha: 'hash'}}, ]; -function mockGetInputDefaultImplementation(key: string): boolean | string { +// `core.getInput` always returns a string, so this returns strings too: the action's inputs go through the real +// ActionUtils.getJSONInput, which JSON.parses whatever it gets back. +function mockGetInputDefaultImplementation(key: string): string { switch (key) { case 'PR_LIST': return JSON.stringify(Object.keys(PRList)); case 'IS_PRODUCTION_DEPLOY': - return false; + return 'false'; case 'DEPLOY_VERSION': return version; case 'IOS': @@ -144,6 +128,7 @@ function mockGetInputDefaultImplementation(key: string): boolean | string { case 'WEB': return 'success'; case 'DATE': + case 'MOBILE_EXPENSIFY_PR_LIST': case 'NOTE': case 'ANDROID_SENTRY_URL': case 'IOS_SENTRY_URL': @@ -209,7 +194,7 @@ platform | result it('comments on pull requests correctly for a standard production deploy', async () => { mockGetInput.mockImplementation((key: string) => { if (key === 'IS_PRODUCTION_DEPLOY') { - return true; + return 'true'; } return mockGetInputDefaultImplementation(key); }); diff --git a/tests/tooling/versionUpdater.test.ts b/tests/tooling/versionUpdater.test.ts index e59288710b85..0e292038feb8 100644 --- a/tests/tooling/versionUpdater.test.ts +++ b/tests/tooling/versionUpdater.test.ts @@ -1,3 +1,5 @@ +import {describe, expect, it, test} from 'bun:test'; + import * as versionUpdater from '../../.github/libs/versionUpdater'; const VERSION = '2.3.9-80'; From 2e81c1f30875eb664fa829783129ffc59486ff6d Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 11 Aug 2026 23:54:03 -0700 Subject: [PATCH 06/24] Satisfy typecheck, ESLint and knip for tests/tooling - @types/bun types the assertion helpers more tightly than @types/jest did, so a few mocks needed adjusting: DeployChecklistUtils' redundant listForRepo mock is folded into the spy that wrapped it, expect.stringContaining (typed `any` by bun-types) becomes toContain, and the two octokit stubs that can't carry the endpoint statics get an explicit disable with the reason. - getPullRequestIncrementalChanges stubs unset inputs as '' rather than null, matching what core.getInput actually returns. - oxfmt sorts bun:test alongside the other test-runner imports, which also reorders the imports in the existing server/ bun tests. - jest-when and @types/jest-when are dropped now that postOrReplaceComment, their only consumer, no longer uses them. --- .oxfmtrc.json | 6 +- package-lock.json | 18 ----- package.json | 2 - .../victory-chart-renderer/tests/log.test.ts | 3 +- .../tests/render.test.ts | 1 + .../tests/resolveCanvasSize.test.ts | 3 +- .../tests/stubExports.test.ts | 3 +- .../victory-chart-renderer/tests/testUtils.ts | 1 + tests/tooling/CIGitLogic.test.ts | 4 +- tests/tooling/DeployChecklistUtils.test.ts | 70 ++++++++++--------- tests/tooling/Git.test.ts | 6 +- tests/tooling/GithubUtils.test.ts | 3 +- tests/tooling/awaitStagingDeploys.test.ts | 6 +- tests/tooling/bumpVersion.test.ts | 1 + tests/tooling/checkDeployBlockers.test.ts | 3 +- .../createOrUpdateDeployChecklist.test.ts | 6 +- tests/tooling/failureNotifier.test.ts | 4 +- .../getPullRequestIncrementalChanges.test.ts | 10 +-- tests/tooling/isAuthorizedContributor.test.ts | 3 +- tests/tooling/isDeployChecklistLocked.test.ts | 3 +- .../markPullRequestsAsDeployed.test.ts | 3 +- tests/tooling/postOrReplaceComment.test.ts | 10 +-- tests/tooling/versionUpdater.test.ts | 2 +- tests/tooling/waitForPreviousRuns.test.ts | 3 +- 24 files changed, 86 insertions(+), 88 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 46e9af39ccf0..edc832fe2b5e 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,8 +11,8 @@ "ignoreCase": true, "customGroups": [ { - "groupName": "jest", - "elementNamePattern": ["@jest/globals", "@testing-library/**"] + "groupName": "test-runner", + "elementNamePattern": ["@jest/globals", "@testing-library/**", "bun:test"] }, {"groupName": "assets", "elementNamePattern": ["@assets/**"]}, {"groupName": "components", "elementNamePattern": ["@components/**"]}, @@ -28,7 +28,7 @@ {"groupName": "src", "elementNamePattern": ["@src/**"]} ], "groups": [ - "jest", + "test-runner", "assets", "components", "github", diff --git a/package-lock.json b/package-lock.json index 4f8b52da0129..11bd0f26742e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -204,7 +204,6 @@ "@types/d3-scale": "^4.0.9", "@types/howler": "^2.2.12", "@types/jest": "^29.5.14", - "@types/jest-when": "^3.5.2", "@types/js-yaml": "^4.0.5", "@types/lodash-es": "4.17.12", "@types/mapbox-gl": "^2.7.13", @@ -257,7 +256,6 @@ "jest-environment-jsdom": "^29.7.0", "jest-expo": "57.0.2", "jest-transformer-svg": "^2.0.1", - "jest-when": "^3.5.2", "knip": "^6.14.0", "lefthook": "2.1.9", "link": "^2.1.1", @@ -18548,14 +18546,6 @@ "pretty-format": "^29.0.0" } }, - "node_modules/@types/jest-when": { - "version": "3.5.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/jest": "*" - } - }, "node_modules/@types/js-yaml": { "version": "4.0.5", "dev": true, @@ -31116,14 +31106,6 @@ "node": ">=8" } }, - "node_modules/jest-when": { - "version": "3.5.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "jest": ">= 25" - } - }, "node_modules/jest-worker": { "version": "27.5.1", "license": "MIT", diff --git a/package.json b/package.json index 15c30c781908..2bfac423e998 100644 --- a/package.json +++ b/package.json @@ -279,7 +279,6 @@ "@types/d3-scale": "^4.0.9", "@types/howler": "^2.2.12", "@types/jest": "^29.5.14", - "@types/jest-when": "^3.5.2", "@types/js-yaml": "^4.0.5", "@types/lodash-es": "4.17.12", "@types/mapbox-gl": "^2.7.13", @@ -332,7 +331,6 @@ "jest-environment-jsdom": "^29.7.0", "jest-expo": "57.0.2", "jest-transformer-svg": "^2.0.1", - "jest-when": "^3.5.2", "knip": "^6.14.0", "lefthook": "2.1.9", "link": "^2.1.1", diff --git a/server/victory-chart-renderer/tests/log.test.ts b/server/victory-chart-renderer/tests/log.test.ts index 73615eca0ec4..ab175b17f962 100644 --- a/server/victory-chart-renderer/tests/log.test.ts +++ b/server/victory-chart-renderer/tests/log.test.ts @@ -1,6 +1,7 @@ -import Log from '@server/libs/log'; import {afterEach, beforeEach, describe, expect, test} from 'bun:test'; +import Log from '@server/libs/log'; + import vcrLog from '../src/log'; // VCR_LOG_DESTINATION=stderr forces the fallback path so these tests are deterministic regardless diff --git a/server/victory-chart-renderer/tests/render.test.ts b/server/victory-chart-renderer/tests/render.test.ts index 73b52ce099da..9f7ee7782022 100644 --- a/server/victory-chart-renderer/tests/render.test.ts +++ b/server/victory-chart-renderer/tests/render.test.ts @@ -1,4 +1,5 @@ import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; + import {spawnSync} from 'node:child_process'; import {chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; diff --git a/server/victory-chart-renderer/tests/resolveCanvasSize.test.ts b/server/victory-chart-renderer/tests/resolveCanvasSize.test.ts index c0972c4244c6..56cd4c86569b 100644 --- a/server/victory-chart-renderer/tests/resolveCanvasSize.test.ts +++ b/server/victory-chart-renderer/tests/resolveCanvasSize.test.ts @@ -1,6 +1,7 @@ +import {describe, expect, test} from 'bun:test'; + import type {TNode} from 'react-native-render-html'; -import {describe, expect, test} from 'bun:test'; import createMock from 'tests/utils/createMock'; import resolveCanvasSize from '../src/resolveCanvasSize'; diff --git a/server/victory-chart-renderer/tests/stubExports.test.ts b/server/victory-chart-renderer/tests/stubExports.test.ts index f137f6d6fcf9..a3c15b6302aa 100644 --- a/server/victory-chart-renderer/tests/stubExports.test.ts +++ b/server/victory-chart-renderer/tests/stubExports.test.ts @@ -1,5 +1,6 @@ -import * as telemetryActiveSpansStub from '@server/stubs/telemetry-activeSpans'; import {describe, expect, test} from 'bun:test'; + +import * as telemetryActiveSpansStub from '@server/stubs/telemetry-activeSpans'; import {readFileSync} from 'node:fs'; import {join} from 'node:path'; diff --git a/server/victory-chart-renderer/tests/testUtils.ts b/server/victory-chart-renderer/tests/testUtils.ts index 849568497faa..6a662b12341c 100644 --- a/server/victory-chart-renderer/tests/testUtils.ts +++ b/server/victory-chart-renderer/tests/testUtils.ts @@ -1,4 +1,5 @@ import {expect} from 'bun:test'; + import {readdirSync, readFileSync} from 'node:fs'; import {arch, platform} from 'node:os'; import {join} from 'node:path'; diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index 01e1d0a726e0..56e49c0271fc 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -1,3 +1,5 @@ +import {afterAll, beforeAll, describe, expect, jest, setDefaultTimeout, test} from 'bun:test'; + import getPreviousVersion from '@github/actions/javascript/getPreviousVersion/getPreviousVersion'; import CONST from '@github/libs/CONST'; import GithubUtils from '@github/libs/GithubUtils'; @@ -6,7 +8,6 @@ import * as VersionUpdater from '@github/libs/versionUpdater'; import type {SemverLevel} from '@github/libs/versionUpdater'; import * as core from '@actions/core'; -import {afterAll, beforeAll, describe, expect, jest, setDefaultTimeout, test} from 'bun:test'; import {execSync} from 'child_process'; import fs from 'fs'; import os from 'os'; @@ -183,6 +184,7 @@ function initGithubAPIMocking() { }), ); }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the stub omits octokit's `defaults`/`endpoint` statics, which this action never touches GithubUtils.octokit.repos.compareCommits = mockCompareCommits as unknown as typeof GithubUtils.octokit.repos.compareCommits; } diff --git a/tests/tooling/DeployChecklistUtils.test.ts b/tests/tooling/DeployChecklistUtils.test.ts index 34a8c79f2208..c5e03a90d244 100644 --- a/tests/tooling/DeployChecklistUtils.test.ts +++ b/tests/tooling/DeployChecklistUtils.test.ts @@ -1,13 +1,13 @@ +import type {Mock} from 'bun:test'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; + import CONST from '@github/libs/CONST'; import {generateDeployChecklistBodyAndAssignees, getDeployChecklist, NoOpenDeployChecklistError} from '@github/libs/DeployChecklistUtils'; import type {InternalOctokit, ListForRepoMethod, OctokitIssueItem} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; -import type {Mock} from 'bun:test'; - /* eslint-disable @typescript-eslint/naming-convention */ import {RequestError} from '@octokit/request-error'; -import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; import createMock from '../utils/createMock'; @@ -19,7 +19,6 @@ type GetPullRequestResponse = Awaited>; const createListForRepoResponse = (data: OctokitIssueItem[]): ListForRepoResponse => createMock({data}); -const mockListIssues = jest.fn(); let listForRepoSpy: Mock; let internalOctokit: InternalOctokit; @@ -64,12 +63,11 @@ beforeAll(() => { } internalOctokit = initializedOctokit; - listForRepoSpy = jest.spyOn(internalOctokit.rest.issues, 'listForRepo').mockImplementation(mockListIssues); + listForRepoSpy = jest.spyOn(internalOctokit.rest.issues, 'listForRepo'); }); afterEach(() => { - listForRepoSpy.mockClear(); - mockListIssues.mockReset(); + listForRepoSpy.mockReset(); }); describe('DeployChecklistUtils', () => { @@ -96,7 +94,7 @@ describe('DeployChecklistUtils', () => { issueWithDeployBlockers.body += `\r\n**Deploy Blockers:**\r\n- [ ] https://github.com/${process.env.GITHUB_REPOSITORY}/issues/1\r\n- [x] https://github.com/${process.env.GITHUB_REPOSITORY}/issues/2\r\n- [ ] https://github.com/${process.env.GITHUB_REPOSITORY}/pull/1234\r\n`; - const baseExpectedResponse: Partial>> = { + const baseExpectedResponse: Awaited> = { PRList: [ { url: `https://github.com/${process.env.GITHUB_REPOSITORY}/pull/21`, @@ -163,23 +161,23 @@ describe('DeployChecklistUtils', () => { body: `**Release Version:** \`1.0.1-47\`\r\n**Compare Changes:** https://github.com/${process.env.GITHUB_REPOSITORY}/compare/production...staging\r\n\r\ncc @Expensify/applauseleads\n`, }); - const bareExpectedResponse: Partial>> = { + const bareExpectedResponse: Awaited> = { ...baseExpectedResponse, PRList: [], PRListMobileExpensify: [], }; - mockListIssues.mockResolvedValue(createListForRepoResponse([bareIssue])); + listForRepoSpy.mockResolvedValue(createListForRepoResponse([bareIssue])); return getDeployChecklist().then((data) => expect(data).toStrictEqual(bareExpectedResponse)); }); test('Test finding an open issue successfully', () => { - mockListIssues.mockResolvedValue(createListForRepoResponse([baseIssue])); + listForRepoSpy.mockResolvedValue(createListForRepoResponse([baseIssue])); return getDeployChecklist().then((data) => expect(data).toStrictEqual(baseExpectedResponse)); }); test('Test finding an open issue successfully and parsing with deploy blockers', () => { - mockListIssues.mockResolvedValue(createListForRepoResponse([issueWithDeployBlockers])); + listForRepoSpy.mockResolvedValue(createListForRepoResponse([issueWithDeployBlockers])); return getDeployChecklist().then((data) => expect(data).toStrictEqual(expectedResponseWithDeployBlockers)); }); @@ -187,14 +185,14 @@ describe('DeployChecklistUtils', () => { const modifiedIssueWithDeployBlockers = {...issueWithDeployBlockers}; modifiedIssueWithDeployBlockers.body = (modifiedIssueWithDeployBlockers.body ?? '').replaceAll('\r', ''); - mockListIssues.mockResolvedValue(createListForRepoResponse([modifiedIssueWithDeployBlockers])); + listForRepoSpy.mockResolvedValue(createListForRepoResponse([modifiedIssueWithDeployBlockers])); return getDeployChecklist().then((data) => expect(data).toStrictEqual(expectedResponseWithDeployBlockers)); }); test('Test finding an open issue without a body', () => { const noBodyIssue = {...baseIssue, body: ''}; - mockListIssues.mockResolvedValue(createListForRepoResponse([noBodyIssue])); + listForRepoSpy.mockResolvedValue(createListForRepoResponse([noBodyIssue])); return getDeployChecklist().then((data) => expect(data).toMatchObject({ PRList: [], @@ -212,12 +210,12 @@ describe('DeployChecklistUtils', () => { test('Test finding an open issue with malformed URL', async () => { const malformedURLIssue = {...baseIssue, url: 'invalid-url'}; - mockListIssues.mockResolvedValue(createListForRepoResponse([malformedURLIssue])); + listForRepoSpy.mockResolvedValue(createListForRepoResponse([malformedURLIssue])); await expect(getDeployChecklist()).rejects.toThrow(`Unable to find ${CONST.LABELS.STAGING_DEPLOY} issue with correct data.`); }); test('Test finding more than one issue', async () => { - mockListIssues.mockResolvedValue(createListForRepoResponse([createMock({number: 1}), createMock({number: 2})])); + listForRepoSpy.mockResolvedValue(createListForRepoResponse([createMock({number: 1}), createMock({number: 2})])); try { await getDeployChecklist(); throw new Error('Expected getDeployChecklist to reject'); @@ -227,7 +225,7 @@ describe('DeployChecklistUtils', () => { }); test('state:open empty + state:all returns closed issue → NoOpenDeployChecklistError', async () => { - mockListIssues + listForRepoSpy .mockResolvedValueOnce(createListForRepoResponse([])) .mockResolvedValueOnce(createListForRepoResponse([createMock({number: 100, state: 'closed'})])); try { @@ -238,12 +236,12 @@ describe('DeployChecklistUtils', () => { if (!(e instanceof Error)) { throw e; } - expect(e.message).toEqual(expect.stringContaining('#100')); + expect(e.message).toContain('#100'); } }); test('state:open empty + state:all returns open issue → fails closed (inconsistency)', async () => { - mockListIssues + listForRepoSpy .mockResolvedValueOnce(createListForRepoResponse([])) .mockResolvedValueOnce(createListForRepoResponse([createMock({number: 500, state: 'open'})])); try { @@ -254,13 +252,13 @@ describe('DeployChecklistUtils', () => { if (!(e instanceof Error)) { throw e; } - expect(e.message).toEqual(expect.stringContaining('Inconsistent GitHub response')); - expect(e.message).toEqual(expect.stringContaining('#500')); + expect(e.message).toContain('Inconsistent GitHub response'); + expect(e.message).toContain('#500'); } }); test('state:open empty + state:all empty → fails closed (pathological)', async () => { - mockListIssues.mockResolvedValue(createListForRepoResponse([])); + listForRepoSpy.mockResolvedValue(createListForRepoResponse([])); try { await getDeployChecklist(); throw new Error('Expected getDeployChecklist to reject'); @@ -269,7 +267,7 @@ describe('DeployChecklistUtils', () => { if (!(e instanceof Error)) { throw e; } - expect(e.message).toEqual(expect.stringContaining(`No StagingDeployCash issues found at all`)); + expect(e.message).toContain(`No StagingDeployCash issues found at all`); } }); }); @@ -279,7 +277,7 @@ describe('DeployChecklistUtils', () => { const err503 = new RequestError('Service Unavailable', 503, { request: {method: 'GET', url: 'https://api.github.com/repos/o/i/issues', headers: {}}, }); - mockListIssues + listForRepoSpy .mockRejectedValueOnce(err503) .mockResolvedValueOnce( createListForRepoResponse([createMock({number: 88, url: 'https://api.github.com/repos/o/i/issues/88', title: 't', labels: [], body: ''})]), @@ -295,7 +293,7 @@ describe('DeployChecklistUtils', () => { const err503 = new RequestError('Service Unavailable', 503, { request: {method: 'GET', url: 'https://api.github.com/repos/o/i/issues', headers: {}}, }); - mockListIssues.mockRejectedValue(err503); + listForRepoSpy.mockRejectedValue(err503); await expect(runWithFakeTimers(() => getDeployChecklist())).rejects.toThrow(RequestError); @@ -303,7 +301,7 @@ describe('DeployChecklistUtils', () => { }); test('does not retry on empty result; falls through to state:all cross-check', async () => { - mockListIssues + listForRepoSpy .mockResolvedValueOnce(createListForRepoResponse([])) .mockResolvedValueOnce(createListForRepoResponse([createMock({number: 200, state: 'closed'})])); await expect(getDeployChecklist()).rejects.toBeInstanceOf(NoOpenDeployChecklistError); @@ -314,7 +312,7 @@ describe('DeployChecklistUtils', () => { const err404 = new RequestError('Not Found', 404, { request: {method: 'GET', url: 'https://api.github.com/repos/o/i/issues', headers: {}}, }); - mockListIssues.mockRejectedValue(err404); + listForRepoSpy.mockRejectedValue(err404); await expect(getDeployChecklist()).rejects.toBeInstanceOf(RequestError); expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(1); @@ -324,7 +322,7 @@ describe('DeployChecklistUtils', () => { const err403 = new RequestError('Secondary rate limit', 403, { request: {method: 'GET', url: 'https://api.github.com/repos/o/i/issues', headers: {}}, }); - mockListIssues + listForRepoSpy .mockRejectedValueOnce(err403) .mockResolvedValueOnce( createListForRepoResponse([createMock({number: 77, url: 'https://api.github.com/repos/o/i/issues/77', title: 't', labels: [], body: ''})]), @@ -337,7 +335,7 @@ describe('DeployChecklistUtils', () => { }); test('state:all reports a non-first open issue → fails closed with that number', async () => { - mockListIssues + listForRepoSpy .mockResolvedValueOnce(createListForRepoResponse([])) .mockResolvedValueOnce( createListForRepoResponse([ @@ -354,8 +352,8 @@ describe('DeployChecklistUtils', () => { if (!(e instanceof Error)) { throw e; } - expect(e.message).toEqual(expect.stringContaining('Inconsistent GitHub response')); - expect(e.message).toEqual(expect.stringContaining('#800')); + expect(e.message).toContain('Inconsistent GitHub response'); + expect(e.message).toContain('#800'); } }); }); @@ -379,8 +377,10 @@ describe('DeployChecklistUtils', () => { }); beforeEach(() => { - paginateSpy.mockImplementation(async () => mockPRs); - getPullRequestSpy.mockImplementation(async (parameters) => { + paginateSpy.mockResolvedValue(mockPRs); + // Octokit endpoint methods carry `defaults`/`endpoint` statics that mockImplementation insists on but + // the action never touches, so the stub only implements the call signature. + const getPullRequest = async (parameters?: Parameters[0]) => { if (!parameters) { throw new Error('Expected pull request parameters.'); } @@ -391,7 +391,9 @@ describe('DeployChecklistUtils', () => { merged_by: pullRequest ? {login: 'octocat'} : null, }, }); - }); + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the stub deliberately omits the statics described above + getPullRequestSpy.mockImplementation(getPullRequest as unknown as OctokitGetPullRequest); }); afterEach(() => { diff --git a/tests/tooling/Git.test.ts b/tests/tooling/Git.test.ts index ff8f50dbb9af..fa020f0b7acd 100644 --- a/tests/tooling/Git.test.ts +++ b/tests/tooling/Git.test.ts @@ -1,6 +1,6 @@ import type {Mock} from 'bun:test'; - import {afterEach, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; + import * as childProcess from 'child_process'; import {Str} from 'expensify-common'; import fs from 'fs'; @@ -11,7 +11,9 @@ import createMock from '../utils/createMock'; // `child_process`'s named exports are read-only live bindings, so replace the whole module. This must run before // `Git` (which imports execSync internally) is imported below, and `bun test --isolate` keeps the replacement from // leaking into the other files in tests/tooling. -const mockExecSync = jest.fn(); +// Typed to the single overload Git.ts actually uses (it always passes `encoding: 'utf8'`), so the test's +// string-returning stubs type-check against it. +const mockExecSync = jest.fn<(command: string, options?: childProcess.ExecSyncOptions) => string>(); await mock.module('child_process', () => ({...childProcess, execSync: mockExecSync})); // Must be imported after the mock.module() call above so it picks up the mock. diff --git a/tests/tooling/GithubUtils.test.ts b/tests/tooling/GithubUtils.test.ts index 1d3b716e6b54..ed5c311865c1 100644 --- a/tests/tooling/GithubUtils.test.ts +++ b/tests/tooling/GithubUtils.test.ts @@ -1,3 +1,5 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; + import CONST from '@github/libs/CONST'; import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; @@ -5,7 +7,6 @@ import GithubUtils from '@github/libs/GithubUtils'; /* eslint-disable @typescript-eslint/naming-convention */ import * as core from '@actions/core'; import {RequestError} from '@octokit/request-error'; -import {afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; const mockGetInput = jest.fn(); const mockListIssues = jest.fn(); diff --git a/tests/tooling/awaitStagingDeploys.test.ts b/tests/tooling/awaitStagingDeploys.test.ts index 302d8aea41d7..38b567537c12 100644 --- a/tests/tooling/awaitStagingDeploys.test.ts +++ b/tests/tooling/awaitStagingDeploys.test.ts @@ -1,13 +1,13 @@ +import type {Mock} from 'bun:test'; +import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; + import run from '@github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys'; import CONST from '@github/libs/CONST'; import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; -import type {Mock} from 'bun:test'; - /* eslint-disable @typescript-eslint/naming-convention */ import * as core from '@actions/core'; -import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; type Workflow = { workflow_id: string; diff --git a/tests/tooling/bumpVersion.test.ts b/tests/tooling/bumpVersion.test.ts index 6417a01be95b..13326d21dc1b 100644 --- a/tests/tooling/bumpVersion.test.ts +++ b/tests/tooling/bumpVersion.test.ts @@ -1,4 +1,5 @@ import {beforeEach, describe, expect, mock, test} from 'bun:test'; + import {fs as memfsFs, vol} from 'memfs'; import path from 'path'; diff --git a/tests/tooling/checkDeployBlockers.test.ts b/tests/tooling/checkDeployBlockers.test.ts index 0523edaa3434..fa599bf6f2c4 100644 --- a/tests/tooling/checkDeployBlockers.test.ts +++ b/tests/tooling/checkDeployBlockers.test.ts @@ -1,9 +1,10 @@ +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; + import run from '@github/actions/javascript/checkDeployBlockers/checkDeployBlockers'; import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; import * as core from '@actions/core'; -import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; type CommentData = {body: string}; diff --git a/tests/tooling/createOrUpdateDeployChecklist.test.ts b/tests/tooling/createOrUpdateDeployChecklist.test.ts index 20be1b4aac91..c8f290fefa96 100644 --- a/tests/tooling/createOrUpdateDeployChecklist.test.ts +++ b/tests/tooling/createOrUpdateDeployChecklist.test.ts @@ -1,13 +1,13 @@ +import type {Mock} from 'bun:test'; +import {afterAll, afterEach, beforeAll, describe, expect, jest, mock, test} from 'bun:test'; + import CONST from '@github/libs/CONST'; import * as DeployChecklistUtils from '@github/libs/DeployChecklistUtils'; import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; import GitUtils from '@github/libs/GitUtils'; -import type {Mock} from 'bun:test'; - import * as core from '@actions/core'; -import {afterAll, afterEach, beforeAll, describe, expect, jest, mock, test} from 'bun:test'; import * as fns from 'date-fns'; import {fs as memfsFs, vol} from 'memfs'; import path from 'path'; diff --git a/tests/tooling/failureNotifier.test.ts b/tests/tooling/failureNotifier.test.ts index 9074e9f73171..adb621db5da2 100644 --- a/tests/tooling/failureNotifier.test.ts +++ b/tests/tooling/failureNotifier.test.ts @@ -1,9 +1,9 @@ +import {describe, expect, it} from 'bun:test'; + /* eslint-disable @typescript-eslint/naming-convention -- matching GitHub API response field names */ import {getMergedPR} from '@github/actions/javascript/failureNotifier/failureNotifier'; import type {PullRequest} from '@github/actions/javascript/failureNotifier/failureNotifier'; -import {describe, expect, it} from 'bun:test'; - describe('getMergedPR', () => { const mergedPR = { html_url: 'https://github.com/Expensify/App/pull/82016', diff --git a/tests/tooling/getPullRequestIncrementalChanges.test.ts b/tests/tooling/getPullRequestIncrementalChanges.test.ts index 11ad6517b740..7fda4a0a5b61 100644 --- a/tests/tooling/getPullRequestIncrementalChanges.test.ts +++ b/tests/tooling/getPullRequestIncrementalChanges.test.ts @@ -1,14 +1,14 @@ +import type {Mock} from 'bun:test'; +import {beforeAll, beforeEach, describe, expect, it, jest} from 'bun:test'; + import run from '@github/actions/javascript/getPullRequestIncrementalChanges/getPullRequestIncrementalChanges'; import GitHubUtils from '@github/libs/GithubUtils'; import type {InternalOctokit} from '@github/libs/GithubUtils'; import Git from '@scripts/utils/Git'; -import type {Mock} from 'bun:test'; - import * as core from '@actions/core'; import {context} from '@actions/github'; -import {beforeAll, beforeEach, describe, expect, it, jest} from 'bun:test'; import createMock from '../utils/createMock'; @@ -70,7 +70,7 @@ describe('getPullRequestIncrementalChanges', () => { }; // Default mocks - mockGetInput.mockReturnValue(null); + mockGetInput.mockReturnValue(''); mockGitEnsureRef.mockResolvedValue(undefined); mockGetPullRequestDiff.mockReset(); paginateSpy.mockReset(); @@ -250,7 +250,7 @@ describe('getPullRequestIncrementalChanges', () => { if (inputName === 'PULL_REQUEST_NUMBER') { return '456'; } - return null; + return ''; }); // Mock paginate to return PR files diff --git a/tests/tooling/isAuthorizedContributor.test.ts b/tests/tooling/isAuthorizedContributor.test.ts index b35599325bd7..5b7e9c5f7608 100644 --- a/tests/tooling/isAuthorizedContributor.test.ts +++ b/tests/tooling/isAuthorizedContributor.test.ts @@ -1,6 +1,7 @@ -import {RequestError} from '@octokit/request-error'; import {afterEach, beforeEach, describe, expect, jest, test} from 'bun:test'; +import {RequestError} from '@octokit/request-error'; + import type {InternalOctokit} from '../../.github/libs/GithubUtils'; import {isAuthorizedContributor, isContributorPlusMember, isInternalExpensifyEngineer} from '../../.github/actions/javascript/isAuthorizedContributor/isAuthorizedContributor'; diff --git a/tests/tooling/isDeployChecklistLocked.test.ts b/tests/tooling/isDeployChecklistLocked.test.ts index f06ab1cb5fdb..4e924213d885 100644 --- a/tests/tooling/isDeployChecklistLocked.test.ts +++ b/tests/tooling/isDeployChecklistLocked.test.ts @@ -1,6 +1,7 @@ -import * as core from '@actions/core'; import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, mock, test} from 'bun:test'; +import * as core from '@actions/core'; + import CONST from '../../.github/libs/CONST'; import * as DeployChecklistUtils from '../../.github/libs/DeployChecklistUtils'; diff --git a/tests/tooling/markPullRequestsAsDeployed.test.ts b/tests/tooling/markPullRequestsAsDeployed.test.ts index 80f4eaedd089..15e94db15c66 100644 --- a/tests/tooling/markPullRequestsAsDeployed.test.ts +++ b/tests/tooling/markPullRequestsAsDeployed.test.ts @@ -1,6 +1,7 @@ -import * as core from '@actions/core'; import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest} from 'bun:test'; +import * as core from '@actions/core'; + import type {InternalOctokit} from '../../.github/libs/GithubUtils'; /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/tests/tooling/postOrReplaceComment.test.ts b/tests/tooling/postOrReplaceComment.test.ts index 9c7c2f19efc1..4fcbb6c8e347 100644 --- a/tests/tooling/postOrReplaceComment.test.ts +++ b/tests/tooling/postOrReplaceComment.test.ts @@ -1,13 +1,13 @@ +import type {Mock} from 'bun:test'; +import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; + import ghAction from '@github/actions/javascript/postOrReplaceComment/postOrReplaceComment'; import CONST from '@github/libs/CONST'; import GithubUtils from '@github/libs/GithubUtils'; -import type {Mock} from 'bun:test'; - import * as core from '@actions/core'; import {context} from '@actions/github'; import * as GitHubEnvironment from '@actions/github/lib/utils'; -import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; import createMock from '../utils/createMock'; @@ -62,7 +62,7 @@ const commentsFetchResponse = createMock({ headers: commentsResponseHeaders, json: () => Promise.resolve(previousCommentsResponse.data), }); -const fetchComments: typeof globalThis.fetch = () => Promise.resolve(commentsFetchResponse); +const fetchComments: typeof globalThis.fetch = Object.assign(() => Promise.resolve(commentsFetchResponse), {preconnect: () => {}}); beforeAll(() => { const getOctokitOptions = GitHubEnvironment.getOctokitOptions; @@ -92,7 +92,7 @@ beforeAll(() => { listCommentsSpy = jest.spyOn(internalOctokit.rest.issues.listComments, 'endpoint'); jest.spyOn(internalOctokit, 'paginate'); graphqlSpy = jest.spyOn(internalOctokit, 'graphql'); - graphqlSpy.mockImplementation(() => Promise.resolve({})); + graphqlSpy.mockResolvedValue({}); }); const androidLink = 'https://expensify.app/ANDROID_LINK'; diff --git a/tests/tooling/versionUpdater.test.ts b/tests/tooling/versionUpdater.test.ts index 0e292038feb8..a63aca84b25b 100644 --- a/tests/tooling/versionUpdater.test.ts +++ b/tests/tooling/versionUpdater.test.ts @@ -8,7 +8,7 @@ const VERSION_NUMBER = [2, 3, 9, 80] as const; describe('versionUpdater', () => { describe('getVersionNumberFromString', () => { it('should return a list with version levels numbers', () => { - expect(versionUpdater.getVersionNumberFromString(VERSION)).toStrictEqual(VERSION_NUMBER); + expect(versionUpdater.getVersionNumberFromString(VERSION)).toStrictEqual([...VERSION_NUMBER]); }); it('should return build as zero if not present in string', () => { diff --git a/tests/tooling/waitForPreviousRuns.test.ts b/tests/tooling/waitForPreviousRuns.test.ts index a7d1b5780abc..8358fbef4c78 100644 --- a/tests/tooling/waitForPreviousRuns.test.ts +++ b/tests/tooling/waitForPreviousRuns.test.ts @@ -1,10 +1,11 @@ +import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; + import run from '@github/actions/javascript/waitForPreviousRuns/waitForPreviousRuns'; import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; /* eslint-disable @typescript-eslint/naming-convention */ import * as core from '@actions/core'; -import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; const CURRENT_RUN_ID = 1000; const WORKFLOW_ID = 'testBuildOnPush.yml'; From 7015ca2195540615633c99463d1a51e21c2845d1 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 00:02:49 -0700 Subject: [PATCH 07/24] Silence console output in the tooling tests Jest's CI job passes --silent, so this output was never visible before; `bun test` has no equivalent flag, and the code under test logs enough to bury the results (2200 lines of output for a 19-file run). Stub the console in the preload instead, behind TEST_VERBOSE. --- tests/tooling/CIGitLogic.test.ts | 2 +- tests/tooling/setup.ts | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index 56e49c0271fc..2e936194af0e 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -23,7 +23,7 @@ const GIT_REMOTE = path.resolve(os.homedir(), 'dummyGitRemotes/DumDumRepo'); const mockGetInput = jest.fn<(name: string) => string | undefined>(); type CompareCommitsCommit = NonNullable>['data']['commits']>[number]; -const isVerbose = process.env.VERBOSE === 'true'; +const isVerbose = process.env.TEST_VERBOSE === 'true'; function exec(command: string) { try { diff --git a/tests/tooling/setup.ts b/tests/tooling/setup.ts index 8283303f58c0..ee00fddc36ac 100644 --- a/tests/tooling/setup.ts +++ b/tests/tooling/setup.ts @@ -1,7 +1,23 @@ -// Preloaded via `bun test --preload` (see the `test:tooling` npm script). GitHub Actions always sets -// GITHUB_REPOSITORY in CI, but local runs need a default, mirroring jest/setup.ts's equivalent fallback for the -// test files Jest still owns. +// Preloaded via `bun test --preload` (see the `test:tooling` npm script), once per test file because --isolate +// gives each file its own globals. +import {jest} from 'bun:test'; + +// GitHub Actions always sets GITHUB_REPOSITORY in CI, but local runs need a default, mirroring jest/setup.ts's +// equivalent fallback for the test files Jest still owns. if (!('GITHUB_REPOSITORY' in process.env)) { process.env.GITHUB_REPOSITORY_OWNER = 'Expensify'; process.env.GITHUB_REPOSITORY = 'Expensify/App'; } + +// The code under test logs heavily, which drowns out the actual results. Jest's CI runs pass --silent for the same +// reason; `bun test` has no equivalent flag, so swap in stubs. Set TEST_VERBOSE=true to see the output. +if (process.env.TEST_VERBOSE !== 'true') { + globalThis.console = { + ...console, + log: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +} From 66e751c03cbdccc7a4634c62b019555f091018c1 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 00:16:59 -0700 Subject: [PATCH 08/24] Address review feedback on the tooling test migration - runWithFakeTimers now advances by the exact backoff schedule the code under test declares, restoring what the Jest version pinned; advancing by an arbitrary amount had made the tests pass for any delay. It also installs the fake timers inside the try, so a synchronous throw can't leave the clock frozen for later tests. - GithubUtils re-installs its core.getInput spy per test: getCommitHistoryBetweenTags' afterEach calls jest.restoreAllMocks(), which under Jest could not reach the plain assignment this replaced. - createOrUpdateDeployChecklist pins the clock. Its assertions re-derive today's date and compare it against the title the action stamped, and Jest's global fake timers used to make that deterministic. test:tooling also pins TZ=utc, as npm test does. - isDeployChecklistLocked drops its module restore, dead under --isolate, along with the comment claiming files share a module registry. - bunTests.yml watches tests/utils/**, which five of these files import. - Git's two `not.toContain(expect.stringContaining(...))` assertions were vacuous: toContain compares with === and ignores asymmetric matchers. - Comment fixes: the @actions/github context justification described an environment that doesn't hold in CI, and CIGitLogic's preamble still described Jest, which no longer runs it. It now also records that a failure cascades, since Bun's --bail can't be scoped to one file. - Tightened the four seatbelt counts that dropped. --- .github/workflows/bunTests.yml | 1 + config/eslint/eslint.config.mjs | 5 +- config/eslint/eslint.seatbelt.tsv | 8 +-- package.json | 2 +- tests/tooling/CIGitLogic.test.ts | 6 +- tests/tooling/DeployChecklistUtils.test.ts | 59 +++++++++++-------- tests/tooling/Git.test.ts | 4 +- tests/tooling/GithubUtils.test.ts | 7 ++- .../createOrUpdateDeployChecklist.test.ts | 5 ++ .../getPullRequestIncrementalChanges.test.ts | 5 +- tests/tooling/isDeployChecklistLocked.test.ts | 11 +--- tests/tooling/postOrReplaceComment.test.ts | 6 +- tests/tooling/setup.ts | 3 +- 13 files changed, 67 insertions(+), 55 deletions(-) diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml index 6732d86b9a12..a9d650330923 100644 --- a/.github/workflows/bunTests.yml +++ b/.github/workflows/bunTests.yml @@ -8,6 +8,7 @@ on: - 'server/**' - 'src/**' - 'tests/tooling/**' + - 'tests/utils/**' - '.github/actions/javascript/**' - '.github/libs/**' - '.github/scripts/**' diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index de5208a6e29e..839d443d76f9 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -711,8 +711,9 @@ const config = defineConfig([ }, rules: { // bun-types declares `expect(...).resolves`/`.rejects` matchers as returning `void` even though Bun's - // own docs recommend (and its runtime requires) awaiting them, so this rule false-positives on that - // pattern here. See https://github.com/oven-sh/bun/pull/23425. + // own docs recommend (and its runtime requires) awaiting them, so this rule reports every correct use + // of that pattern here. See https://github.com/oven-sh/bun/pull/23425. The cost of turning it off is + // that a *missing* await on `.rejects` also lints clean, so check those by hand in review. '@typescript-eslint/await-thenable': 'off', }, }, diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 98e1e9851432..28e8e4d52708 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1683,7 +1683,7 @@ "../../tests/unit/FormulaTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 "../../tests/unit/FormulaTest.ts" "no-restricted-imports" 1 "../../tests/unit/FraudProtectionTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/tooling/GithubUtils.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../tests/tooling/GithubUtils.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts" "no-restricted-imports" 1 "../../tests/unit/HrUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -1777,7 +1777,7 @@ "../../tests/unit/components/ScreenWrapperPreventRemoveTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/compoundParamsKeyTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/tooling/createOrUpdateDeployChecklist.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/tooling/createOrUpdateDeployChecklist.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/deferredLayoutWriteTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/dismissModalAndOpenReportInInboxTabTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/findUnusedStylesTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -1828,7 +1828,7 @@ "../../tests/unit/hooks/useTransactionThreadReportIDs.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/inlineEditing/editableCellHooks.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/tooling/isAuthorizedContributor.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/tooling/isDeployChecklistLocked.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/tooling/isDeployChecklistLocked.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/isReportMessageAttachmentTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/isReportOpenOrUnsubmittedTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/keyboard/AndroidKeyboardUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1841,7 +1841,7 @@ "../../tests/unit/libs/TravelUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/libs/getClipboardTextTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/libs/navigateAfterOnboarding.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/tooling/markPullRequestsAsDeployed.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../tests/tooling/markPullRequestsAsDeployed.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/navigateAfterOnboardingTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/navigateAfterOnboardingTest.ts" "no-restricted-imports" 1 "../../tests/unit/navigateToWorkspacesPageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 14 diff --git a/package.json b/package.json index 2bfac423e998..c3691b404e9d 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer --", "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer --", "test:bun": "bun test --concurrent --max-concurrency 7", - "test:tooling": "bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling", + "test:tooling": "TZ=utc bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling", "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer --", "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer --", "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer --" diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index 2e936194af0e..1c9e8720e2fa 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -462,17 +462,19 @@ async function assertPRsMergedBetween(from: string, to: string, expected: number } /* - * These tests are different from most jest tests. They create a dummy git repo and simulate the GitHub Actions CI environment + * These tests are different from most of the suite. They create a dummy git repo and simulate the GitHub Actions CI environment * and ensure that deploy checklists, comments, and releases are created correctly and completely, * including a number of real-world edge cases we have encountered and fixed. * * However, because they are different, there are a few additional "rules" with these tests: * - They should not be run in parallel with other tests on the same machine. They will not play nicely with other tests. * - The whole suite should be run. Running individual tests from the suite may not work as expected. + * - Each test builds on the repo state the previous one left behind, so the first failure cascades into the rest. + * Re-run with `--bail` to see only the first one; Bun has no per-file equivalent. */ // These tests shell out to real `git`/`npm` subprocesses many times per test and can exceed the default 5000ms -// per-test timeout (shared with Jest's default), especially on a cold cache. +// per-test timeout, especially on a cold cache. setDefaultTimeout(30000); let startingDir: string; diff --git a/tests/tooling/DeployChecklistUtils.test.ts b/tests/tooling/DeployChecklistUtils.test.ts index c5e03a90d244..7e276038f24c 100644 --- a/tests/tooling/DeployChecklistUtils.test.ts +++ b/tests/tooling/DeployChecklistUtils.test.ts @@ -23,36 +23,43 @@ let listForRepoSpy: Mock; let internalOctokit: InternalOctokit; /** - * Runs `operation` with fake timers so its retry backoff resolves instantly. Bun only exposes a synchronous - * `jest.advanceTimersByTime`, and the code under test schedules each backoff timer from a `catch` block - i.e. only - * once the preceding rejection has been awaited - so alternate between yielding to the microtask queue and pushing - * the clock forward until the operation settles. + * Runs `operation` with fake timers so its retry backoff resolves instantly, advancing the clock by exactly + * `expectedBackoffsMs` in order. Advancing by the exact delays rather than some arbitrarily large amount keeps + * these tests pinned to LIST_RETRY_DELAYS_MS: lengthen a delay there and the operation never settles. + * + * Bun only exposes a synchronous `jest.advanceTimersByTime`, and the code under test schedules each backoff timer + * from a `catch` block - i.e. several microtasks after the call starts - so yield until that timer exists before + * firing it. */ -function runWithFakeTimers(operation: () => Promise): Promise { +async function runWithFakeTimers(operation: () => Promise, expectedBackoffsMs: number[]): Promise { jest.useFakeTimers(); - let isSettled = false; - const pending = operation().finally(() => { - isSettled = true; - }); + try { + let isSettled = false; + const pending = operation().finally(() => { + isSettled = true; + }); - // The caller decides whether a rejection is expected; swallow it here only so driving the clock below doesn't - // trip Bun's unhandled-rejection reporting in the meantime. - pending.catch(() => {}); + // The caller decides whether a rejection is expected; swallow it here only so driving the clock below + // doesn't trip Bun's unhandled-rejection reporting in the meantime. + pending.catch(() => {}); - return (async () => { - try { - for (let i = 0; !isSettled && i < 1000; i++) { + for (const backoffMs of expectedBackoffsMs) { + for (let i = 0; jest.getTimerCount() === 0 && !isSettled && i < 100; i++) { await Promise.resolve(); - jest.advanceTimersByTime(1000); } - if (!isSettled) { - throw new Error('Operation did not settle after advancing fake timers; is it waiting on something other than a timer?'); - } - return await pending; - } finally { - jest.useRealTimers(); + jest.advanceTimersByTime(backoffMs); } - })(); + + for (let i = 0; !isSettled && i < 100; i++) { + await Promise.resolve(); + } + if (!isSettled) { + throw new Error(`Operation did not settle after advancing the clock by ${expectedBackoffsMs.join(' + ')}ms; did its retry schedule change?`); + } + return await pending; + } finally { + jest.useRealTimers(); + } } beforeAll(() => { @@ -283,7 +290,7 @@ describe('DeployChecklistUtils', () => { createListForRepoResponse([createMock({number: 88, url: 'https://api.github.com/repos/o/i/issues/88', title: 't', labels: [], body: ''})]), ); - const data = await runWithFakeTimers(() => getDeployChecklist()); + const data = await runWithFakeTimers(() => getDeployChecklist(), [2000]); expect(data.number).toBe(88); expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(2); @@ -295,7 +302,7 @@ describe('DeployChecklistUtils', () => { }); listForRepoSpy.mockRejectedValue(err503); - await expect(runWithFakeTimers(() => getDeployChecklist())).rejects.toThrow(RequestError); + await expect(runWithFakeTimers(() => getDeployChecklist(), [2000, 5000])).rejects.toThrow(RequestError); expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(3); }); @@ -328,7 +335,7 @@ describe('DeployChecklistUtils', () => { createListForRepoResponse([createMock({number: 77, url: 'https://api.github.com/repos/o/i/issues/77', title: 't', labels: [], body: ''})]), ); - const data = await runWithFakeTimers(() => getDeployChecklist()); + const data = await runWithFakeTimers(() => getDeployChecklist(), [2000]); expect(data.number).toBe(77); expect(GithubUtils.octokit.issues.listForRepo).toHaveBeenCalledTimes(2); diff --git a/tests/tooling/Git.test.ts b/tests/tooling/Git.test.ts index fa020f0b7acd..e2810ea12253 100644 --- a/tests/tooling/Git.test.ts +++ b/tests/tooling/Git.test.ts @@ -1258,7 +1258,7 @@ describe('Git', () => { // Verify git ls-files was not called const calls = mockExecSync.mock.calls.map((call) => call[0]); - expect(calls).not.toContain(expect.stringContaining('git ls-files')); + expect(calls.some((command) => command.includes('git ls-files'))).toBe(false); }); it('does not include untracked files when toRef is provided even if shouldIncludeUntrackedFiles is true', () => { @@ -1268,7 +1268,7 @@ describe('Git', () => { expect(mockExecSync).toHaveBeenCalledTimes(1); const calls = mockExecSync.mock.calls.map((call) => call[0]); - expect(calls).not.toContain(expect.stringContaining('git ls-files')); + expect(calls.some((command) => command.includes('git ls-files'))).toBe(false); }); it('includes untracked files when shouldIncludeUntrackedFiles is true and toRef is undefined', () => { diff --git a/tests/tooling/GithubUtils.test.ts b/tests/tooling/GithubUtils.test.ts index ed5c311865c1..9068849232a4 100644 --- a/tests/tooling/GithubUtils.test.ts +++ b/tests/tooling/GithubUtils.test.ts @@ -17,11 +17,14 @@ type ObjectMethodData = { type OctokitCreateIssue = InternalOctokit['rest']['issues']['create']; -beforeAll(() => { +beforeEach(() => { // Mock core module. Real ESM module namespace exports are read-only live bindings, so `core.getInput` can't be - // reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. + // reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. Re-installed per test + // rather than once, because getCommitHistoryBetweenTags' afterEach calls jest.restoreAllMocks(). jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); +}); +beforeAll(() => { // Mock octokit module const mockOctokit = { rest: { diff --git a/tests/tooling/createOrUpdateDeployChecklist.test.ts b/tests/tooling/createOrUpdateDeployChecklist.test.ts index c8f290fefa96..985e86f94057 100644 --- a/tests/tooling/createOrUpdateDeployChecklist.test.ts +++ b/tests/tooling/createOrUpdateDeployChecklist.test.ts @@ -36,6 +36,10 @@ const mockGetMergedPRsDeployedBetween = jest.fn() as Mock { + // The action stamps the checklist title with today's date and the assertions below re-derive it, so pin the + // clock: otherwise the two reads can straddle local midnight. Jest froze Date globally via fakeTimers. + jest.setSystemTime(new Date('2026-02-03T12:00:00Z')); + // Mock octokit module const mockOctokit = { rest: { @@ -100,6 +104,7 @@ afterEach(() => { afterAll(() => { jest.clearAllMocks(); + jest.useRealTimers(); }); const LABELS = { diff --git a/tests/tooling/getPullRequestIncrementalChanges.test.ts b/tests/tooling/getPullRequestIncrementalChanges.test.ts index 7fda4a0a5b61..d8acf5a6c04d 100644 --- a/tests/tooling/getPullRequestIncrementalChanges.test.ts +++ b/tests/tooling/getPullRequestIncrementalChanges.test.ts @@ -22,8 +22,9 @@ let paginateSpy: Mock; const mockGetInput = jest.fn(); // Bun has no equivalent of `jest.mock(path)`'s automock, so stub the @actions/core functions this action calls -// explicitly. `@actions/github`'s `context` needs no stub: it is a plain mutable object whose constructor is a -// no-op without GITHUB_EVENT_PATH set, so each test can assign to it directly (see beforeEach below). +// explicitly. `@actions/github`'s `context` needs no stub: it is a plain mutable object, and beforeEach below +// overwrites every field this action reads, so whatever the real constructor loaded from the environment is +// irrelevant. jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); const mockSetOutput = jest.spyOn(core, 'setOutput').mockImplementation(() => {}); jest.spyOn(core, 'warning').mockImplementation(() => {}); diff --git a/tests/tooling/isDeployChecklistLocked.test.ts b/tests/tooling/isDeployChecklistLocked.test.ts index 4e924213d885..e1fd68b8ce81 100644 --- a/tests/tooling/isDeployChecklistLocked.test.ts +++ b/tests/tooling/isDeployChecklistLocked.test.ts @@ -7,12 +7,6 @@ import * as DeployChecklistUtils from '../../.github/libs/DeployChecklistUtils'; const mockGetDeployChecklist = jest.fn(); -// Capture the real exports by value before mocking: `DeployChecklistUtils` is a live namespace binding tied to -// the shared module registry entry, so once mock.module() below replaces that entry, `DeployChecklistUtils` would -// itself resolve to the mocked exports too - which would make the afterAll restoration below a no-op that -// "restores" the mock forever instead of the real module. -const originalDeployChecklistUtils = {...DeployChecklistUtils}; - // Must run before `isDeployChecklistLocked` (which imports DeployChecklistUtils internally) is imported below: // mock.module patches the shared module registry entry, and existing named-import bindings to it are live, but // only if the patch happens before those bindings are first read. @@ -36,11 +30,8 @@ afterEach(() => { jest.restoreAllMocks(); }); -afterAll(async () => { +afterAll(() => { delete process.env.INPUT_GITHUB_TOKEN; - // `bun test` runs all files in one process sharing the module registry, unlike Jest's per-file registry, so - // mock.module's patch would otherwise leak into every other test file that imports this module afterwards. - await mock.module('../../.github/libs/DeployChecklistUtils', () => originalDeployChecklistUtils); }); describe('isDeployChecklistLockedTest', () => { diff --git a/tests/tooling/postOrReplaceComment.test.ts b/tests/tooling/postOrReplaceComment.test.ts index 4fcbb6c8e347..60501edf82ef 100644 --- a/tests/tooling/postOrReplaceComment.test.ts +++ b/tests/tooling/postOrReplaceComment.test.ts @@ -24,9 +24,9 @@ let internalOctokit: InternalOctokit; let listCommentsSpy: Mock; let graphqlSpy: Mock; -// `context` is a plain object instance whose constructor is a no-op without GITHUB_EVENT_PATH set, so it can be -// mutated directly rather than mocking the module. `context.repo` reads GITHUB_REPOSITORY, which tests/tooling/setup.ts -// defaults to Expensify/App. +// `context` is a plain object instance, so the fields this action reads can be assigned directly rather than +// mocking the module. `context.repo` derives from GITHUB_REPOSITORY, which tests/tooling/setup.ts defaults to +// Expensify/App, and `runId` is fixed here so the expected messages below don't depend on the environment. context.runId = 1234; /** diff --git a/tests/tooling/setup.ts b/tests/tooling/setup.ts index ee00fddc36ac..041f41b2d113 100644 --- a/tests/tooling/setup.ts +++ b/tests/tooling/setup.ts @@ -1,5 +1,6 @@ // Preloaded via `bun test --preload` (see the `test:tooling` npm script), once per test file because --isolate -// gives each file its own globals. +// gives each file its own globals. Always run this directory through that script: several files replace `fs` or +// `child_process` with mock.module(), which without --isolate would reach every file that runs after them. import {jest} from 'bun:test'; // GitHub Actions always sets GITHUB_REPOSITORY in CI, but local runs need a default, mirroring jest/setup.ts's From ad374530c3ad5f535ac9cb4ce9b9b90dbe6dace1 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 00:31:35 -0700 Subject: [PATCH 09/24] Gate main on the Bun tests, and document tests/tooling Moving these files out of tests/unit/ took them out of test.yml, which preDeploy.yml calls on every merge to main - so the tests covering preDeploy's own machinery (isDeployChecklistLocked, the deploy checklist, markPullRequestsAsDeployed) stopped gating the deploy they guard. Give bunTests.yml a workflow_call trigger and add it to preDeploy alongside typecheck/lint/test. That also closes the same pre-existing gap for the server tests. Split bunTests.yml into two jobs so a server-test failure no longer hides the tooling results, and so the git-heavy tooling suite runs alongside the victory-chart-renderer build rather than after it. The rest is documentation, because nothing told a contributor that this directory exists or how to run it: - tests/tooling/README.md covers how to run the suite and a single file (the leading ./ and the flags are both load-bearing), the rule for what belongs here rather than in tests/unit/, and the bun:test/Jest API gaps that shaped these files. - README.md, tests/README.md and CLAUDE.md all described Jest as the only unit-test runner. - bunfig.toml's comment claimed Jest owns all of tests/. Also: typecheck.yml's paths filter listed tsconfig.json literally, so editing tests/tooling/tsconfig.json (or server/tsconfig.json) skipped typecheck entirely. --- .github/workflows/bunTests.yml | 19 +++++++++-- .github/workflows/preDeploy.yml | 9 +++-- .github/workflows/typecheck.yml | 2 +- CLAUDE.md | 6 +++- README.md | 1 + bunfig.toml | 3 +- tests/README.md | 4 +++ tests/tooling/README.md | 58 +++++++++++++++++++++++++++++++++ tests/tooling/setup.ts | 3 +- 9 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 tests/tooling/README.md diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml index a9d650330923..8c604942df55 100644 --- a/.github/workflows/bunTests.yml +++ b/.github/workflows/bunTests.yml @@ -1,6 +1,7 @@ name: Bun tests on: + workflow_call: pull_request: types: [opened, synchronize] branches-ignore: [staging, production] @@ -37,11 +38,23 @@ jobs: - name: Run Bun tests run: npm run test:bun - - name: Run tooling tests (.github + scripts) - run: npm run test:tooling - - name: Build victory-chart-renderer Linux x64 binary run: npm run server:vcr:build:linux - name: Smoke-test compiled victory-chart-renderer binary run: ./server/victory-chart-renderer/dist/victory-chart-renderer-linux-x64 --chart-xml "$(cat server/victory-chart-renderer/tests/fixtures/top-categories-6.xml)" --out /tmp/vcr-smoke.png && test -s /tmp/vcr-smoke.png + + # A separate job so a server-test failure doesn't hide the tooling results, and so the git-heavy tooling suite + # runs alongside rather than after the victory-chart-renderer build. + tooling: + name: Tooling tests + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Run tooling tests + run: npm run test:tooling diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 15183af70f87..80121fa9cdb3 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -21,18 +21,21 @@ jobs: uses: ./.github/workflows/test.yml secrets: inherit + bunTests: + uses: ./.github/workflows/bunTests.yml + confirmPassingBuild: runs-on: blacksmith-4vcpu-ubuntu-2404 - needs: [typecheck, lint, test] + needs: [typecheck, lint, test, bunTests] if: ${{ always() }} steps: - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Exit failed workflow - if: ${{ needs.typecheck.result == 'failure' || needs.lint.result == 'failure' || needs.test.result == 'failure' }} + if: ${{ needs.typecheck.result == 'failure' || needs.lint.result == 'failure' || needs.test.result == 'failure' || needs.bunTests.result == 'failure' }} run: | - echo "Checks failed, exiting ~ typecheck: ${{ needs.typecheck.result }}, lint: ${{ needs.lint.result }}, test: ${{ needs.test.result }}" + echo "Checks failed, exiting ~ typecheck: ${{ needs.typecheck.result }}, lint: ${{ needs.lint.result }}, test: ${{ needs.test.result }}, bunTests: ${{ needs.bunTests.result }}" exit 1 chooseDeployActions: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 8bf164abf6b5..90cdf1780ecb 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -5,7 +5,7 @@ on: pull_request: types: [opened, synchronize] branches-ignore: [staging, production] - paths: ['**.js', '**.ts', '**.tsx', 'package.json', 'package-lock.json', 'tsconfig.json'] + paths: ['**.js', '**.ts', '**.tsx', 'package.json', 'package-lock.json', '**/tsconfig.json'] concurrency: group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-typecheck diff --git a/CLAUDE.md b/CLAUDE.md index 549cced9e1e7..8cd7a0b90483 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,7 +232,8 @@ React Compiler auto-memoizes object literals, callbacks, JSX, and derived values ### Testing -- **Unit Tests**: Jest with React Native Testing Library +- **Unit Tests**: Jest with React Native Testing Library. Tests for `.github/` and `scripts/` live in + `tests/tooling/` and run under `bun:test` (`npm run test:tooling`) — see `tests/tooling/README.md`. - **Performance Tests**: Reassure framework ## Special Considerations @@ -293,6 +294,9 @@ npm run fmt # Testing npm run test + +# Testing the repo's own tooling (.github/ and scripts/) +npm run test:tooling ``` ### Platform Builds diff --git a/README.md b/README.md index a46e1b31cc5a..b41454f8b68a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Often times in order to write a unit test, you may need to mock data, a componen to help run our Unit tests. * To run the **Jest unit tests**: `npm run test` +* To run the **tooling tests** (`.github/` and `scripts/`, which use `bun:test`): `npm run test:tooling` — see [tests/tooling/README.md](tests/tooling/README.md) * UI tests guidelines can be found [here](tests/ui/README.md) ## Performance tests diff --git a/bunfig.toml b/bunfig.toml index 781d91320191..a3f50ed01418 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,8 @@ # `import` and `require()`, so scripts don't load the real Flow-typed RN packages at runtime). preload = ["./scripts/stubReactNative.js"] -# Bun test discovery for server-side tooling (Jest owns src/ and tests/). +# Where a bare `bun test` looks. tests/tooling/ is deliberately excluded: it needs the --isolate and --preload +# flags in the `test:tooling` npm script, so it must be invoked through that (see tests/tooling/README.md). [test] root = "server" pathIgnorePatterns = ["**/fixtures/**", "**/dist/**", "**/.dev/**", "**/__output__/**", "**/__golden__/**"] diff --git a/tests/README.md b/tests/README.md index 657650d809d0..d26b7b17e683 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,6 +2,10 @@ [Jest](https://jestjs.io/) is a testing framework we use to ensure our most mission critical libraries are as stable as possible. Here are a few things to consider with regards to our app's architecture when testing in Jest. +> Jest covers everything except `tests/tooling/`, which tests the repo's own build and deploy tooling and runs under +> `bun:test` instead. If your test's import graph reaches `@actions/*` or `@octokit/*`, it belongs there — see +> [tests/tooling/README.md](tooling/README.md). + ## Asynchronous Testing - Much of the logic in the app is asynchronous in nature. [`react-native-onyx`](https://github.com/expensify/react-native-onyx) writes data async before updating subscribers. diff --git a/tests/tooling/README.md b/tests/tooling/README.md new file mode 100644 index 000000000000..4e53c1b2cad8 --- /dev/null +++ b/tests/tooling/README.md @@ -0,0 +1,58 @@ +# Tooling tests + +Tests for the code that builds, deploys and lints this repo — `.github/actions/`, `.github/libs/`, +`.github/scripts/` and `scripts/` — as opposed to the app itself. They run under +[`bun:test`](https://bun.com/docs/cli/test), not Jest. + +## Running them + +```sh +# The whole directory (this is what CI runs) +npm run test:tooling + +# One file — pass the flags yourself, the npm script can't forward a path +TZ=utc bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts + +# One test, by name +npm run test:tooling -- -t 'getPullRequestNumberFromURL' + +# Show the output the code under test writes (silenced by default, see setup.ts) +TEST_VERBOSE=true npm run test:tooling +``` + +The leading `./` on a file path is required: without it Bun treats the argument as a name filter and finds +nothing, because `bunfig.toml` points bare `bun test` at `server/`. + +`--isolate` is not optional. Several files replace `fs`, `child_process` or a shared lib with `mock.module()`, +and Bun shares one module registry across files unless each gets its own. + +## Why bun:test and not Jest + +`@actions/core` and `@actions/github` are ESM-only from their next majors. Jest resolves them through Babel's +CommonJS interop, so keeping these tests on Jest would mean maintaining hand-built CJS shims for every +`@actions/*` and `@octokit/*` package. Bun imports them natively. + +That is also the rule for where a new test belongs: **if its import graph reaches `@actions/*` or `@octokit/*`, +it goes here.** Everything else — including scripts that only use `scripts/utils/*` — can stay in `tests/unit/` +under Jest. + +## Differences from the Jest tests + +Bun's `jest` object is close to Jest's but not identical. The gaps that come up here: + +| Jest | Bun equivalent | +| --- | --- | +| `jest.mock('foo')` (automock) | `mock.module('foo', factory)`, before the module under test is imported. There is no automock, so stub each export you need. | +| `jest.requireActual('foo')` | `await import('foo')` before the `mock.module()` call. | +| `jest.advanceTimersByTimeAsync(ms)` | No equivalent; alternate `await Promise.resolve()` with `jest.advanceTimersByTime(ms)`. | +| `jest.mocked(fn)` / `jest.SpiedFunction` | `Mock` from `bun:test`. | +| `asMutable(core).getInput = mock` | `jest.spyOn(core, 'getInput')` — real ESM namespace exports are read-only. | + +Because `mock.module()` is hoisting-sensitive, files that use it import the module under test with a top-level +`await import(...)` placed after the mock. + +## Shared helpers + +Anything these files import from `tests/utils/` gets pulled into `tests/tooling/tsconfig.json` as well as the +root config, so it is type-checked once with `@types/jest` and once with `@types/bun`. Only import helpers from +there that use neither runner's globals — `createMock` is the one in use today. diff --git a/tests/tooling/setup.ts b/tests/tooling/setup.ts index 041f41b2d113..b81a5502c6c9 100644 --- a/tests/tooling/setup.ts +++ b/tests/tooling/setup.ts @@ -11,7 +11,8 @@ if (!('GITHUB_REPOSITORY' in process.env)) { } // The code under test logs heavily, which drowns out the actual results. Jest's CI runs pass --silent for the same -// reason; `bun test` has no equivalent flag, so swap in stubs. Set TEST_VERBOSE=true to see the output. +// reason; `bun test` has no equivalent flag, so swap in stubs. Set TEST_VERBOSE=true to see the output. Workflow +// commands that @actions/core writes straight to process.stdout still come through. if (process.env.TEST_VERBOSE !== 'true') { globalThis.console = { ...console, From 7f72bc9c9048f5a4c53f7da86063351d3ac9f5c6 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 00:58:02 -0700 Subject: [PATCH 10/24] Qualify the Bun tests concurrency group by SHA on main Adding the workflow_call trigger meant this group is now evaluated for pushes to main, where github.ref is the same for every merge - so two merges in quick succession would cancel each other's run. That is the bug Expensify/App#41936 fixed for lint and test, and it would be worse here: confirmPassingBuild only treats 'failure' as failing, so a cancelled bunTests would pass the gate silently. Use the same SHA-qualified group the other preDeploy workflows use. Also drop GithubUtils' core.getInput stub, which had no implementation and no assertions behind it - the tests that need core stub it themselves - and move useFakeTimers inside the try that restores them. --- .github/workflows/bunTests.yml | 2 +- tests/tooling/DeployChecklistUtils.test.ts | 2 +- tests/tooling/GithubUtils.test.ts | 9 --------- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml index 8c604942df55..7257de8a4c8a 100644 --- a/.github/workflows/bunTests.yml +++ b/.github/workflows/bunTests.yml @@ -21,7 +21,7 @@ on: - '.github/workflows/bunTests.yml' concurrency: - group: ${{ github.ref }}-bun-tests + group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-bun-tests cancel-in-progress: true jobs: diff --git a/tests/tooling/DeployChecklistUtils.test.ts b/tests/tooling/DeployChecklistUtils.test.ts index 7e276038f24c..369b73330701 100644 --- a/tests/tooling/DeployChecklistUtils.test.ts +++ b/tests/tooling/DeployChecklistUtils.test.ts @@ -32,8 +32,8 @@ let internalOctokit: InternalOctokit; * firing it. */ async function runWithFakeTimers(operation: () => Promise, expectedBackoffsMs: number[]): Promise { - jest.useFakeTimers(); try { + jest.useFakeTimers(); let isSettled = false; const pending = operation().finally(() => { isSettled = true; diff --git a/tests/tooling/GithubUtils.test.ts b/tests/tooling/GithubUtils.test.ts index 9068849232a4..25a8b441c368 100644 --- a/tests/tooling/GithubUtils.test.ts +++ b/tests/tooling/GithubUtils.test.ts @@ -8,7 +8,6 @@ import GithubUtils from '@github/libs/GithubUtils'; import * as core from '@actions/core'; import {RequestError} from '@octokit/request-error'; -const mockGetInput = jest.fn(); const mockListIssues = jest.fn(); type ObjectMethodData = { @@ -17,13 +16,6 @@ type ObjectMethodData = { type OctokitCreateIssue = InternalOctokit['rest']['issues']['create']; -beforeEach(() => { - // Mock core module. Real ESM module namespace exports are read-only live bindings, so `core.getInput` can't be - // reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. Re-installed per test - // rather than once, because getCommitHistoryBetweenTags' afterEach calls jest.restoreAllMocks(). - jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); -}); - beforeAll(() => { // Mock octokit module const mockOctokit = { @@ -47,7 +39,6 @@ beforeAll(() => { }); afterEach(() => { - mockGetInput.mockClear(); mockListIssues.mockClear(); }); From 843852e7fbe89a7966385a4b10f5c7bb2066d1df Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 07:53:00 -0700 Subject: [PATCH 11/24] Move the last four @actions-importing tests to bun:test These four transitively import @actions/core at runtime, so they would have broken under Jest the moment the ESM-only upgrade landed. Moving them now means no Jest test can reach @actions/* or @octokit/* any more, which is the whole point of this PR: nothing in tests/unit/ needs a CJS shim built for it. Per-file notes: - artifactsResolver replaces child_process, githubCLI and GithubUtils with mock.module() in place of three jest.mock() automocks. Typing the paginate mock properly also retires two no-unsafe-type-assertion disables. - generateTranslations swaps `en` per test via a fresh mock.module() rather than a getter over a mutable variable: the script imports `en` as a default binding, which Bun resolves once at link time, so a getter only fires for the first test. Its Git stubs are now spies on the three static methods the script actually calls, rather than an automock of the whole class, and the diff fixtures gained the `diffType` the real Git.diff always sets - inert at runtime, but it means the fixtures now type-check against FileDiff. - ChatGPTTranslator drops its OpenAIUtils automock. Only `promptResponses` was ever stubbed, and the constructor it was hiding just stores a key. - createRetestRequestForCP needed only its bun:test imports. test:tooling now also preloads scripts/stubReactNative.js: generateTranslations reads src/languages/en, which pulls in react-native, whose Flow syntax Bun can't parse. Jest got away with it because Babel transformed react-native. This is the same stub `bun scripts/generateTranslations.ts` already runs with - bunfig.toml's top-level preload doesn't apply to `bun test`. Counts are unchanged: the four ran 63 tests under Jest and run 63 here, so tests/tooling is 23 files and 316 tests. --- config/eslint/eslint.seatbelt.tsv | 2 +- package.json | 2 +- .../ChatGPTTranslator.test.ts} | 9 +- tests/tooling/README.md | 12 ++- .../artifactsResolver.test.ts} | 55 ++++++----- .../createRetestRequestForCP.test.ts} | 6 +- .../generateTranslations.test.ts} | 97 +++++++++++-------- 7 files changed, 101 insertions(+), 82 deletions(-) rename tests/{unit/ChatGPTTranslatorTest.ts => tooling/ChatGPTTranslator.test.ts} (94%) rename tests/{unit/ArtifactsResolverTest.ts => tooling/artifactsResolver.test.ts} (75%) rename tests/{unit/createRetestRequestForCPTest.ts => tooling/createRetestRequestForCP.test.ts} (98%) rename tests/{unit/generateTranslationsTest.ts => tooling/generateTranslations.test.ts} (97%) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 28e8e4d52708..8bf3b916f4d4 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1781,7 +1781,7 @@ "../../tests/unit/deferredLayoutWriteTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/dismissModalAndOpenReportInInboxTabTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/findUnusedStylesTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../tests/unit/generateTranslationsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/tooling/generateTranslations.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/getChartSkiaTypefaceTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/getTimeDifferenceIntervalsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/goBackFromWorkspaceSettingPagesTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/package.json b/package.json index c3691b404e9d..10812e4926cd 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer --", "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer --", "test:bun": "bun test --concurrent --max-concurrency 7", - "test:tooling": "TZ=utc bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling", + "test:tooling": "TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling", "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer --", "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer --", "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer --" diff --git a/tests/unit/ChatGPTTranslatorTest.ts b/tests/tooling/ChatGPTTranslator.test.ts similarity index 94% rename from tests/unit/ChatGPTTranslatorTest.ts rename to tests/tooling/ChatGPTTranslator.test.ts index 8d78d03867be..ca49c0164904 100644 --- a/tests/unit/ChatGPTTranslatorTest.ts +++ b/tests/tooling/ChatGPTTranslator.test.ts @@ -1,13 +1,12 @@ -/** - * @jest-environment node - */ +import {beforeEach, describe, expect, it, jest} from 'bun:test'; + import OpenAIUtils from '@scripts/utils/OpenAIUtils'; import ChatGPTTranslator from '@scripts/utils/Translator/ChatGPTTranslator'; import type Locale from '@src/types/onyx/Locale'; -jest.mock('@scripts/utils/OpenAIUtils'); - +// Only `promptResponses` needs stubbing: the OpenAIUtils constructor just stores the key and builds a client, so +// there is nothing to gain from replacing the whole module (which Bun has no automock for anyway). const mockedPromptResponses = jest.spyOn(OpenAIUtils.prototype, 'promptResponses'); /** diff --git a/tests/tooling/README.md b/tests/tooling/README.md index 4e53c1b2cad8..eef37d3ff80f 100644 --- a/tests/tooling/README.md +++ b/tests/tooling/README.md @@ -11,7 +11,7 @@ Tests for the code that builds, deploys and lints this repo — `.github/actions npm run test:tooling # One file — pass the flags yourself, the npm script can't forward a path -TZ=utc bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts +TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts # One test, by name npm run test:tooling -- -t 'getPullRequestNumberFromURL' @@ -23,8 +23,14 @@ TEST_VERBOSE=true npm run test:tooling The leading `./` on a file path is required: without it Bun treats the argument as a name filter and finds nothing, because `bunfig.toml` points bare `bun test` at `server/`. -`--isolate` is not optional. Several files replace `fs`, `child_process` or a shared lib with `mock.module()`, -and Bun shares one module registry across files unless each gets its own. +Neither flag is optional: + +- `--isolate` — several files replace `fs`, `child_process` or a shared lib with `mock.module()`, and Bun shares + one module registry across files unless each gets its own. +- `--preload ./scripts/stubReactNative.js` — `generateTranslations.test.ts` reaches `src/languages/en`, which + pulls in `react-native`, whose Flow syntax Bun can't parse. `bunfig.toml`'s top-level `preload` does not apply + to `bun test`, so it has to be passed here. It's the same stub `bun scripts/generateTranslations.ts` itself runs + with. ## Why bun:test and not Jest diff --git a/tests/unit/ArtifactsResolverTest.ts b/tests/tooling/artifactsResolver.test.ts similarity index 75% rename from tests/unit/ArtifactsResolverTest.ts rename to tests/tooling/artifactsResolver.test.ts index 02bee6f5133f..d798ce3290cb 100644 --- a/tests/unit/ArtifactsResolverTest.ts +++ b/tests/tooling/artifactsResolver.test.ts @@ -1,30 +1,34 @@ -import GithubUtils from '@github/libs/GithubUtils'; +import {afterEach, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; -import resolveArtifacts, {ARTIFACT_IDS} from '@scripts/artifacts-utils/lib/artifactsResolver'; -import {getCredentials} from '@scripts/artifacts-utils/lib/githubCLI'; +import type GithubUtils from '@github/libs/GithubUtils'; -/** - * @jest-environment node - */ -import {execFileSync} from 'child_process'; +import type {getCredentials} from '@scripts/artifacts-utils/lib/githubCLI'; + +import * as childProcess from 'child_process'; import fs from 'fs'; -jest.mock('child_process'); -jest.mock('@scripts/artifacts-utils/lib/githubCLI'); -jest.mock('@github/libs/GithubUtils', () => ({ - __esModule: true, +const mockExecFileSync = jest.fn<(command: string) => string>(); +const mockGetCredentials = jest.fn(); +const mockPaginate = jest.fn<() => Promise>>(); +const mockInitGithubClient = jest.fn(); + +// Bun has no equivalent of `jest.mock(path)`'s automock, so each of these replaces the module explicitly. They must +// run before `artifactsResolver` is imported below: mock.module patches the shared module registry entry, and +// existing import bindings are live, but only if the patch happens before those bindings are first read. +// `bun test --isolate` keeps the replacements from reaching the other files in tests/tooling. +await mock.module('child_process', () => ({...childProcess, execFileSync: mockExecFileSync})); +const realGithubCLI = await import('@scripts/artifacts-utils/lib/githubCLI'); +await mock.module('@scripts/artifacts-utils/lib/githubCLI', () => ({...realGithubCLI, getCredentials: mockGetCredentials})); +await mock.module('@github/libs/GithubUtils', () => ({ default: { - initOctokitWithToken: jest.fn(), - paginate: jest.fn(), + initOctokitWithToken: mockInitGithubClient, + paginate: mockPaginate, octokit: {packages: {getAllPackageVersionsForPackageOwnedByOrg: jest.fn()}}, }, })); -const mockExecFileSync = jest.mocked(execFileSync); -const mockGetCredentials = jest.mocked(getCredentials); -const mockPaginate = jest.mocked(GithubUtils.paginate); -// eslint-disable-next-line @typescript-eslint/unbound-method -- jest.fn() mocks don't rely on `this` binding -const mockInitGithubClient = jest.mocked(GithubUtils.initOctokitWithToken); +// Must be imported after the mock.module() calls above so it picks up the mocks. +const {default: resolveArtifacts, ARTIFACT_IDS} = await import('@scripts/artifacts-utils/lib/artifactsResolver'); const NEW_DOT_ROOT = '/repo'; const LOCAL_HASH = 'abc123hash'; @@ -39,19 +43,21 @@ function fakeFetchResponse(body: string) { /** Replaces global fetch with a queue of POM responses (one per candidate lookup). */ function mockFetchBodies(bodies: string[]) { let call = 0; - global.fetch = jest.fn().mockImplementation(() => Promise.resolve(fakeFetchResponse(bodies.at(call++) ?? ''))); + // `preconnect` is part of the fetch type but nothing here calls it. + global.fetch = Object.assign( + jest.fn().mockImplementation(() => Promise.resolve(fakeFetchResponse(bodies.at(call++) ?? ''))), + {preconnect: () => {}}, + ); } /** Makes the package-versions API return the given version names. */ function mockVersions(names: string[]) { - // Faking the paginate() surface in a unit test. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (mockPaginate as unknown as jest.Mock).mockResolvedValue(names.map((name) => ({name}))); + mockPaginate.mockResolvedValue(names.map((name) => ({name}))); } /** Mocks the local patches hash and the react-native version read from package.json. */ function mockLocalRepo() { - mockExecFileSync.mockImplementation((cmd: string) => (cmd === 'bash' ? LOCAL_HASH : '')); + mockExecFileSync.mockImplementation((command: string) => (command === 'bash' ? LOCAL_HASH : '')); jest.spyOn(fs, 'readFileSync').mockReturnValue('{"dependencies":{"react-native":"0.85.3"}}'); } @@ -141,8 +147,7 @@ describe('artifactsResolver', () => { it('falls back to source build when the packages API fails', async () => { mockLocalRepo(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (mockPaginate as unknown as jest.Mock).mockRejectedValue(new Error('403 Forbidden')); + mockPaginate.mockRejectedValue(new Error('403 Forbidden')); const result = await resolveArtifacts({platform: 'ios', packageName: 'react-hybrid', newDotRoot: NEW_DOT_ROOT, isHybrid: true}); diff --git a/tests/unit/createRetestRequestForCPTest.ts b/tests/tooling/createRetestRequestForCP.test.ts similarity index 98% rename from tests/unit/createRetestRequestForCPTest.ts rename to tests/tooling/createRetestRequestForCP.test.ts index dac34082b397..77f9247e1370 100644 --- a/tests/unit/createRetestRequestForCPTest.ts +++ b/tests/tooling/createRetestRequestForCP.test.ts @@ -1,12 +1,10 @@ +import {describe, expect, it} from 'bun:test'; + import CONST from '@github/libs/CONST'; import {buildRetestPayload, getCherryPickSourceSHAs, getLinkedIssueNumbers, getRetestMarker} from '@scripts/createRetestRequestForCP'; import type {RetestHit} from '@scripts/createRetestRequestForCP'; -/** - * @jest-environment node - */ - describe('createRetestRequestForCP', () => { describe('getCherryPickSourceSHAs', () => { it('pulls the source SHA out of a cherry-pick trailer', () => { diff --git a/tests/unit/generateTranslationsTest.ts b/tests/tooling/generateTranslations.test.ts similarity index 97% rename from tests/unit/generateTranslationsTest.ts rename to tests/tooling/generateTranslations.test.ts index b872a86f94c3..4a220ba8989e 100644 --- a/tests/unit/generateTranslationsTest.ts +++ b/tests/tooling/generateTranslations.test.ts @@ -1,44 +1,42 @@ -import generateTranslations, {GENERATED_FILE_PREFIX} from '@scripts/generateTranslations'; +import {afterAll, afterEach, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; +import type {Mock} from 'bun:test'; + import Git from '@scripts/utils/Git'; import DummyTranslator from '@scripts/utils/Translator/DummyTranslator'; import Translator from '@scripts/utils/Translator/Translator'; -/** - * @jest-environment node - */ import {Str} from 'expensify-common'; import fs from 'fs'; import os from 'os'; import path from 'path'; -let processExitSpy: jest.SpyInstance; -let consoleErrorSpy: jest.SpyInstance; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment -let mockEn: any = jest.requireActual('@src/languages/en'); -jest.mock('@src/languages/en', () => ({ - __esModule: true, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - get default() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return mockEn; - }, -})); -jest.mock('openai'); -jest.mock('@scripts/utils/Git'); - -// Mock Git methods -const mockIsValidRef = jest.fn(); -const mockDiff = jest.fn(); -const mockShow = jest.fn(); - -// Apply mocks to Git using jest.spyOn (ignore type errors for now) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -jest.spyOn(Git as any, 'isValidRef').mockImplementation(mockIsValidRef); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -jest.spyOn(Git as any, 'diff').mockImplementation(mockDiff); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -jest.spyOn(Git as any, 'show').mockImplementation(mockShow); +let processExitSpy: Mock; +let consoleErrorSpy: Mock; + +/** + * Swaps the `en` strings the script reads as its source of truth. Re-mocking per call rather than reading a mutable + * variable through a getter: `generateTranslations` imports `en` as a default binding, which Bun resolves once at + * link time, so only a fresh mock.module() updates it. + * + * The first call has to happen before `generateTranslations` is imported below, because mock.module patches the + * shared module registry entry and existing bindings only pick that up if the patch lands first. `bun test + * --isolate` keeps the replacement from reaching the other files in tests/tooling. + */ +async function setMockEn(strings: unknown) { + await mock.module('@src/languages/en', () => ({default: strings})); +} + +await setMockEn((await import('@src/languages/en')).default); + +// Must be imported after the mock.module() call above so it reads the mocked `en`. +const {default: generateTranslations, GENERATED_FILE_PREFIX} = await import('@scripts/generateTranslations'); + +// `Git` is a class of static methods, so the three the script calls can be spied on directly. Its remaining +// methods are left real: the script never reaches them under --dry-run, and stubbing them would only hide it if +// that changed. +const mockIsValidRef = jest.spyOn(Git, 'isValidRef'); +const mockDiff = jest.spyOn(Git, 'diff'); +const mockShow = jest.spyOn(Git, 'show'); let tempDir: string; let LANGUAGES_DIR: string; @@ -598,6 +596,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([17]), // Line with newKey removedLines: new Set(), @@ -641,7 +640,7 @@ describe('generateTranslations', () => { network: 'Network error', }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -717,7 +716,7 @@ describe('generateTranslations', () => { }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -787,7 +786,7 @@ describe('generateTranslations', () => { save: 'Save', }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -843,7 +842,7 @@ describe('generateTranslations', () => { save: 'Save', }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -895,7 +894,7 @@ describe('generateTranslations', () => { generic: 'An error occurred', }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -961,7 +960,7 @@ describe('generateTranslations', () => { }, simpleTemplate: (name: string) => `Welcome ${name} to our app`, }; - mockEn = strings; + await setMockEn(strings); // Create English source file fs.writeFileSync( @@ -1091,6 +1090,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([10]), // Line with newKey removedLines: new Set(), @@ -1191,6 +1191,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([2, 3, 4]), // Lines in the complex template removedLines: new Set(), @@ -1273,6 +1274,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([6]), // Line with updated value removedLines: new Set([7, 9, 10, 11, 12]), // Lines where sections were removed @@ -1366,6 +1368,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([6, 7, 8, 9, 10, 11, 12]), // Lines with the new manualTest section removedLines: new Set(), @@ -1451,6 +1454,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([8]), // Line with the new property removedLines: new Set(), @@ -1522,6 +1526,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set(), removedLines: new Set(), @@ -1572,7 +1577,7 @@ describe('generateTranslations', () => { }, }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -1640,7 +1645,7 @@ describe('generateTranslations', () => { }, }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -1726,6 +1731,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([3]), // Only the context comment line removedLines: new Set(), @@ -1805,6 +1811,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([3]), // New context comment removedLines: new Set([3]), // Old context comment on same line in old version @@ -1847,13 +1854,13 @@ describe('generateTranslations', () => { }); it('detects modifications when a context annotation is removed with --compare-ref', async () => { - // Update mockEn to match the test data + // Point the script at this test's strings const strings = { unchanged: 'This stays the same', pin: 'Pin', alsoUnchanged: 'Also unchanged', }; - mockEn = strings; + await setMockEn(strings); // Create English source without context annotation fs.writeFileSync( @@ -1892,6 +1899,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set(), removedLines: new Set([3]), // Context comment removed @@ -1974,6 +1982,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([3]), // Regular comment line removedLines: new Set(), @@ -2049,6 +2058,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([3]), // Modified comment removedLines: new Set([3]), // Old comment @@ -2229,6 +2239,7 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', + diffType: 'modified', hunks: [], addedLines: new Set([1, 2, 5, 6]), // Import, empty, dedent middle/end removedLines: new Set([2]), // Old line 2: prop1: 'First property', @@ -2259,7 +2270,7 @@ describe('generateTranslations', () => { }); describe('error summary', () => { - let consoleLogSpy: jest.SpyInstance; + let consoleLogSpy: Mock; beforeEach(() => { consoleLogSpy = jest.spyOn(console, 'log'); From 6c0e0bd968af77f263de79394412ed97c8cf815a Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 08:10:05 -0700 Subject: [PATCH 12/24] Keep generateTranslationsTest on Jest; narrow ChatGPTTranslator's imports Migrating generateTranslationsTest ran fine under bun:test (40/40), but it can't be type-checked there. tests/tooling type-checks with @types/bun, whose ambient JSX declarations conflict with the app's React and react-native types, and the script under test imports src/languages/en - which drags ~3,000 app files into the project and reports thousands of errors that the root project, with `jest` types instead of `bun`, does not. Confirmed both directions: main's tsconfig finds no errors in src/, and swapping only the `types` array reproduces them. The test side can't narrow that graph, because the import comes from the script. Moving it needs the pure logic extracted or a separate type-check strategy, so it stays on Jest here and still has to move before @actions/* goes ESM-only. Recorded in tests/tooling/README.md alongside the constraint itself, so the next person doesn't rediscover it. ChatGPTTranslator hit the same wall for a reason that *was* fixable: it imported Locale from @src/types/onyx/Locale, which re-exports the whole @src/CONST barrel. It now uses TranslationTargetLocale from @src/CONST/LOCALES - self-contained, and the type the API it exercises actually takes. The stubReactNative preload goes away with generateTranslations, since nothing else here reaches react-native. --- config/eslint/eslint.seatbelt.tsv | 2 +- package.json | 2 +- tests/tooling/ChatGPTTranslator.test.ts | 4 +- tests/tooling/README.md | 23 +++-- .../generateTranslationsTest.ts} | 97 ++++++++----------- 5 files changed, 61 insertions(+), 67 deletions(-) rename tests/{tooling/generateTranslations.test.ts => unit/generateTranslationsTest.ts} (97%) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 33d2faef1407..c36c55663b50 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1779,7 +1779,7 @@ "../../tests/unit/deferredLayoutWriteTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/dismissModalAndOpenReportInInboxTabTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/findUnusedStylesTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../tests/tooling/generateTranslations.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/unit/generateTranslationsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/getChartSkiaTypefaceTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/getTimeDifferenceIntervalsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/goBackFromWorkspaceSettingPagesTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/package.json b/package.json index 97989c5b6752..a532f1cd7c4d 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer --", "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer --", "test:bun": "bun test --concurrent --max-concurrency 7", - "test:tooling": "TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling", + "test:tooling": "TZ=utc bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling", "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer --", "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer --", "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer --" diff --git a/tests/tooling/ChatGPTTranslator.test.ts b/tests/tooling/ChatGPTTranslator.test.ts index ca49c0164904..55dd0cbd63a2 100644 --- a/tests/tooling/ChatGPTTranslator.test.ts +++ b/tests/tooling/ChatGPTTranslator.test.ts @@ -3,7 +3,7 @@ import {beforeEach, describe, expect, it, jest} from 'bun:test'; import OpenAIUtils from '@scripts/utils/OpenAIUtils'; import ChatGPTTranslator from '@scripts/utils/Translator/ChatGPTTranslator'; -import type Locale from '@src/types/onyx/Locale'; +import type {TranslationTargetLocale} from '@src/CONST/LOCALES'; // Only `promptResponses` needs stubbing: the OpenAIUtils constructor just stores the key and builds a client, so // there is nothing to gain from replacing the whole module (which Bun has no automock for anyway). @@ -18,7 +18,7 @@ function mockResponse(text: string, responseID = 'resp_test_123') { describe('ChatGPTTranslator.performTranslation', () => { const apiKey = 'test-api-key'; - const targetLang: Locale = 'it'; + const targetLang: TranslationTargetLocale = 'it'; const maxRetries = 8; // eslint-disable-next-line no-template-curly-in-string const original = 'Hello ${name}!'; diff --git a/tests/tooling/README.md b/tests/tooling/README.md index eef37d3ff80f..4576d951b616 100644 --- a/tests/tooling/README.md +++ b/tests/tooling/README.md @@ -11,7 +11,7 @@ Tests for the code that builds, deploys and lints this repo — `.github/actions npm run test:tooling # One file — pass the flags yourself, the npm script can't forward a path -TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts +TZ=utc bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts # One test, by name npm run test:tooling -- -t 'getPullRequestNumberFromURL' @@ -23,14 +23,8 @@ TEST_VERBOSE=true npm run test:tooling The leading `./` on a file path is required: without it Bun treats the argument as a name filter and finds nothing, because `bunfig.toml` points bare `bun test` at `server/`. -Neither flag is optional: - -- `--isolate` — several files replace `fs`, `child_process` or a shared lib with `mock.module()`, and Bun shares - one module registry across files unless each gets its own. -- `--preload ./scripts/stubReactNative.js` — `generateTranslations.test.ts` reaches `src/languages/en`, which - pulls in `react-native`, whose Flow syntax Bun can't parse. `bunfig.toml`'s top-level `preload` does not apply - to `bun test`, so it has to be passed here. It's the same stub `bun scripts/generateTranslations.ts` itself runs - with. +`--isolate` is not optional. Several files replace `fs`, `child_process` or a shared lib with `mock.module()`, +and Bun shares one module registry across files unless each gets its own. ## Why bun:test and not Jest @@ -42,6 +36,17 @@ That is also the rule for where a new test belongs: **if its import graph reache it goes here.** Everything else — including scripts that only use `scripts/utils/*` — can stay in `tests/unit/` under Jest. +There is one hard constraint on top of that rule: **nothing here may pull `src/` into its type graph.** This +directory type-checks with `@types/bun`, whose ambient JSX declarations conflict with the app's React and +react-native types, so a file that reaches `src/CONST` or `src/types/onyx` drags ~3,000 app files into this +project and produces thousands of spurious errors. Import the narrowest module that has what you need — +`@src/CONST/LOCALES` is self-contained, `@src/types/onyx/Locale` re-exports the whole `@src/CONST` barrel. + +That constraint is why `tests/unit/generateTranslationsTest.ts` is still on Jest even though the script it covers +reaches `@actions/*`: the script itself imports `src/languages/en`, so its type graph can't be narrowed from the +test side. It needs the pure logic extracted, or its own type-check strategy, before it can move — and it has to +move before `@actions/*` goes ESM-only. + ## Differences from the Jest tests Bun's `jest` object is close to Jest's but not identical. The gaps that come up here: diff --git a/tests/tooling/generateTranslations.test.ts b/tests/unit/generateTranslationsTest.ts similarity index 97% rename from tests/tooling/generateTranslations.test.ts rename to tests/unit/generateTranslationsTest.ts index 4a220ba8989e..b872a86f94c3 100644 --- a/tests/tooling/generateTranslations.test.ts +++ b/tests/unit/generateTranslationsTest.ts @@ -1,42 +1,44 @@ -import {afterAll, afterEach, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; -import type {Mock} from 'bun:test'; - +import generateTranslations, {GENERATED_FILE_PREFIX} from '@scripts/generateTranslations'; import Git from '@scripts/utils/Git'; import DummyTranslator from '@scripts/utils/Translator/DummyTranslator'; import Translator from '@scripts/utils/Translator/Translator'; +/** + * @jest-environment node + */ import {Str} from 'expensify-common'; import fs from 'fs'; import os from 'os'; import path from 'path'; -let processExitSpy: Mock; -let consoleErrorSpy: Mock; - -/** - * Swaps the `en` strings the script reads as its source of truth. Re-mocking per call rather than reading a mutable - * variable through a getter: `generateTranslations` imports `en` as a default binding, which Bun resolves once at - * link time, so only a fresh mock.module() updates it. - * - * The first call has to happen before `generateTranslations` is imported below, because mock.module patches the - * shared module registry entry and existing bindings only pick that up if the patch lands first. `bun test - * --isolate` keeps the replacement from reaching the other files in tests/tooling. - */ -async function setMockEn(strings: unknown) { - await mock.module('@src/languages/en', () => ({default: strings})); -} - -await setMockEn((await import('@src/languages/en')).default); - -// Must be imported after the mock.module() call above so it reads the mocked `en`. -const {default: generateTranslations, GENERATED_FILE_PREFIX} = await import('@scripts/generateTranslations'); - -// `Git` is a class of static methods, so the three the script calls can be spied on directly. Its remaining -// methods are left real: the script never reaches them under --dry-run, and stubbing them would only hide it if -// that changed. -const mockIsValidRef = jest.spyOn(Git, 'isValidRef'); -const mockDiff = jest.spyOn(Git, 'diff'); -const mockShow = jest.spyOn(Git, 'show'); +let processExitSpy: jest.SpyInstance; +let consoleErrorSpy: jest.SpyInstance; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment +let mockEn: any = jest.requireActual('@src/languages/en'); +jest.mock('@src/languages/en', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + get default() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return mockEn; + }, +})); +jest.mock('openai'); +jest.mock('@scripts/utils/Git'); + +// Mock Git methods +const mockIsValidRef = jest.fn(); +const mockDiff = jest.fn(); +const mockShow = jest.fn(); + +// Apply mocks to Git using jest.spyOn (ignore type errors for now) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +jest.spyOn(Git as any, 'isValidRef').mockImplementation(mockIsValidRef); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +jest.spyOn(Git as any, 'diff').mockImplementation(mockDiff); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +jest.spyOn(Git as any, 'show').mockImplementation(mockShow); let tempDir: string; let LANGUAGES_DIR: string; @@ -596,7 +598,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([17]), // Line with newKey removedLines: new Set(), @@ -640,7 +641,7 @@ describe('generateTranslations', () => { network: 'Network error', }, }; - await setMockEn(strings); + mockEn = strings; fs.writeFileSync( EN_PATH, @@ -716,7 +717,7 @@ describe('generateTranslations', () => { }, }; - await setMockEn(strings); + mockEn = strings; fs.writeFileSync( EN_PATH, @@ -786,7 +787,7 @@ describe('generateTranslations', () => { save: 'Save', }, }; - await setMockEn(strings); + mockEn = strings; fs.writeFileSync( EN_PATH, @@ -842,7 +843,7 @@ describe('generateTranslations', () => { save: 'Save', }, }; - await setMockEn(strings); + mockEn = strings; fs.writeFileSync( EN_PATH, @@ -894,7 +895,7 @@ describe('generateTranslations', () => { generic: 'An error occurred', }, }; - await setMockEn(strings); + mockEn = strings; fs.writeFileSync( EN_PATH, @@ -960,7 +961,7 @@ describe('generateTranslations', () => { }, simpleTemplate: (name: string) => `Welcome ${name} to our app`, }; - await setMockEn(strings); + mockEn = strings; // Create English source file fs.writeFileSync( @@ -1090,7 +1091,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([10]), // Line with newKey removedLines: new Set(), @@ -1191,7 +1191,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([2, 3, 4]), // Lines in the complex template removedLines: new Set(), @@ -1274,7 +1273,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([6]), // Line with updated value removedLines: new Set([7, 9, 10, 11, 12]), // Lines where sections were removed @@ -1368,7 +1366,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([6, 7, 8, 9, 10, 11, 12]), // Lines with the new manualTest section removedLines: new Set(), @@ -1454,7 +1451,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([8]), // Line with the new property removedLines: new Set(), @@ -1526,7 +1522,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set(), removedLines: new Set(), @@ -1577,7 +1572,7 @@ describe('generateTranslations', () => { }, }, }; - await setMockEn(strings); + mockEn = strings; fs.writeFileSync( EN_PATH, @@ -1645,7 +1640,7 @@ describe('generateTranslations', () => { }, }, }; - await setMockEn(strings); + mockEn = strings; fs.writeFileSync( EN_PATH, @@ -1731,7 +1726,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([3]), // Only the context comment line removedLines: new Set(), @@ -1811,7 +1805,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([3]), // New context comment removedLines: new Set([3]), // Old context comment on same line in old version @@ -1854,13 +1847,13 @@ describe('generateTranslations', () => { }); it('detects modifications when a context annotation is removed with --compare-ref', async () => { - // Point the script at this test's strings + // Update mockEn to match the test data const strings = { unchanged: 'This stays the same', pin: 'Pin', alsoUnchanged: 'Also unchanged', }; - await setMockEn(strings); + mockEn = strings; // Create English source without context annotation fs.writeFileSync( @@ -1899,7 +1892,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set(), removedLines: new Set([3]), // Context comment removed @@ -1982,7 +1974,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([3]), // Regular comment line removedLines: new Set(), @@ -2058,7 +2049,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([3]), // Modified comment removedLines: new Set([3]), // Old comment @@ -2239,7 +2229,6 @@ describe('generateTranslations', () => { files: [ { filePath: 'src/languages/en.ts', - diffType: 'modified', hunks: [], addedLines: new Set([1, 2, 5, 6]), // Import, empty, dedent middle/end removedLines: new Set([2]), // Old line 2: prop1: 'First property', @@ -2270,7 +2259,7 @@ describe('generateTranslations', () => { }); describe('error summary', () => { - let consoleLogSpy: Mock; + let consoleLogSpy: jest.SpyInstance; beforeEach(() => { consoleLogSpy = jest.spyOn(console, 'log'); From 9229a8fd57fecf7b573a0b430c38da4137f7e8a7 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 09:16:22 -0700 Subject: [PATCH 13/24] Fix CI: stale action bundle, undeclared dep, two unknown words Three unrelated failures on this PR: - `verify` rebuilt isDeployChecklistLocked's bundle and found a diff. Main recently made the html-entities patch idempotent by folding the "worklet" directive onto the "use strict" line, but the committed bundle still embeds the old two-line form, so every PR branched off current main fails this check. Rebuilt against the current patch. Not introduced here - it reproduces on plain main - but it blocks this PR, so it's fixed here. - `Compare knip issues against main` reported three new `unlisted` findings, which were the same three pre-existing ones at their new tests/tooling paths. @octokit/request-error is imported directly by six files (three of these tests plus .github/libs/GithubUtils, .github/libs/isTeamMember and getPullRequestIncrementalChanges) but only ever resolved transitively through @actions/github, so declaring it resolves the finding rather than relocating it. - `spellcheck` flagged "Backoffs" and "bunfig". The first is gone: runWithFakeTimers' parameter is now expectedDelaysMs, matching the LIST_RETRY_DELAYS_MS constant it is pinned to. The second is a real filename, so it goes in the dictionary. The Android HybridApp job also failed, at "Load files from 1Password". That is infrastructure - the iOS job on the same runner passed, and nothing here touches the build - so there is nothing to fix for it. --- .../javascript/isDeployChecklistLocked/index.js | 3 +-- cspell.json | 1 + package-lock.json | 3 +++ package.json | 1 + tests/tooling/DeployChecklistUtils.test.ts | 10 +++++----- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index d7e2be21c739..ef9016c20ca0 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -16091,8 +16091,7 @@ function isObject(obj) { /***/ 2589: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { -"worklet"; // This function is used in react-native-live-markdown parser and it must be a worklet to run in UI thread (react-native-reanimated) -"use strict";var __assign=this&&this.__assign||function(){__assign=Object.assign||function(t){for(var s,i=1,n=arguments.length;i'"&]/g,nonAscii:/[<>'"&\u0080-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,nonAsciiPrintable:/[<>'"&\x01-\x08\x11-\x15\x17-\x1F\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,nonAsciiPrintableOnly:/[\x01-\x08\x11-\x15\x17-\x1F\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,extensive:/[\x01-\x0c\x0e-\x1f\x21-\x2c\x2e-\x2f\x3a-\x40\x5b-\x60\x7b-\x7d\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g};var defaultEncodeOptions={mode:"specialChars",level:"all",numeric:"decimal"};function encode(text,_a){var _b=_a===void 0?defaultEncodeOptions:_a,_c=_b.mode,mode=_c===void 0?"specialChars":_c,_d=_b.numeric,numeric=_d===void 0?"decimal":_d,_e=_b.level,level=_e===void 0?"all":_e;if(!text){return""}var encodeRegExp=encodeRegExps[mode];var references=allNamedReferences[level].characters;var isHex=numeric==="hexadecimal";return text.replace(encodeRegExp,(function(input){var result=references[input];if(!result){var code=input.length>1?surrogate_pairs_1.getCodePoint(input,0):input.charCodeAt(0);result=(isHex?"&#x"+code.toString(16):"&#"+code)+";"}return result}))}exports.encode=encode;var defaultDecodeOptions={scope:"body",level:"all"};var strict=/&(?:#\d+|#[xX][\da-fA-F]+|[0-9a-zA-Z]+);/g;var attribute=/&(?:#\d+|#[xX][\da-fA-F]+|[0-9a-zA-Z]+)[;=]?/g;var baseDecodeRegExps={xml:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.xml},html4:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.html4},html5:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.html5}};var decodeRegExps=__assign(__assign({},baseDecodeRegExps),{all:baseDecodeRegExps.html5});var fromCharCode=String.fromCharCode;var outOfBoundsChar=fromCharCode(65533);var defaultDecodeEntityOptions={level:"all"};function getDecodedEntity(entity,references,isAttribute,isStrict){var decodeResult=entity;var decodeEntityLastChar=entity[entity.length-1];if(isAttribute&&decodeEntityLastChar==="="){decodeResult=entity}else if(isStrict&&decodeEntityLastChar!==";"){decodeResult=entity}else{var decodeResultByReference=references[entity];if(decodeResultByReference){decodeResult=decodeResultByReference}else if(entity[0]==="&"&&entity[1]==="#"){var decodeSecondChar=entity[2];var decodeCode=decodeSecondChar=="x"||decodeSecondChar=="X"?parseInt(entity.substr(3),16):parseInt(entity.substr(2));decodeResult=decodeCode>=1114111?outOfBoundsChar:decodeCode>65535?surrogate_pairs_1.fromCodePoint(decodeCode):fromCharCode(numeric_unicode_map_1.numericUnicodeMap[decodeCode]||decodeCode)}}return decodeResult}function decodeEntity(entity,_a){var _b=(_a===void 0?defaultDecodeEntityOptions:_a).level,level=_b===void 0?"all":_b;if(!entity){return""}return getDecodedEntity(entity,allNamedReferences[level].entities,false,false)}exports.decodeEntity=decodeEntity;function decode(text,_a){var _b=_a===void 0?defaultDecodeOptions:_a,_c=_b.level,level=_c===void 0?"all":_c,_d=_b.scope,scope=_d===void 0?level==="xml"?"strict":"body":_d;if(!text){return""}var decodeRegExp=decodeRegExps[level][scope];var references=allNamedReferences[level].entities;var isAttribute=scope==="attribute";var isStrict=scope==="strict";return text.replace(decodeRegExp,(function(entity){return getDecodedEntity(entity,references,isAttribute,isStrict)}))}exports.decode=decode; +"worklet"; /* This function is used in the react-native-live-markdown parser and must be a worklet to run on the UI thread (react-native-reanimated). Kept on the same line as "use strict" so re-running patch-package detects the patch as already applied instead of inserting another copy. */ "use strict";var __assign=this&&this.__assign||function(){__assign=Object.assign||function(t){for(var s,i=1,n=arguments.length;i'"&]/g,nonAscii:/[<>'"&\u0080-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,nonAsciiPrintable:/[<>'"&\x01-\x08\x11-\x15\x17-\x1F\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,nonAsciiPrintableOnly:/[\x01-\x08\x11-\x15\x17-\x1F\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,extensive:/[\x01-\x0c\x0e-\x1f\x21-\x2c\x2e-\x2f\x3a-\x40\x5b-\x60\x7b-\x7d\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g};var defaultEncodeOptions={mode:"specialChars",level:"all",numeric:"decimal"};function encode(text,_a){var _b=_a===void 0?defaultEncodeOptions:_a,_c=_b.mode,mode=_c===void 0?"specialChars":_c,_d=_b.numeric,numeric=_d===void 0?"decimal":_d,_e=_b.level,level=_e===void 0?"all":_e;if(!text){return""}var encodeRegExp=encodeRegExps[mode];var references=allNamedReferences[level].characters;var isHex=numeric==="hexadecimal";return text.replace(encodeRegExp,(function(input){var result=references[input];if(!result){var code=input.length>1?surrogate_pairs_1.getCodePoint(input,0):input.charCodeAt(0);result=(isHex?"&#x"+code.toString(16):"&#"+code)+";"}return result}))}exports.encode=encode;var defaultDecodeOptions={scope:"body",level:"all"};var strict=/&(?:#\d+|#[xX][\da-fA-F]+|[0-9a-zA-Z]+);/g;var attribute=/&(?:#\d+|#[xX][\da-fA-F]+|[0-9a-zA-Z]+)[;=]?/g;var baseDecodeRegExps={xml:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.xml},html4:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.html4},html5:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.html5}};var decodeRegExps=__assign(__assign({},baseDecodeRegExps),{all:baseDecodeRegExps.html5});var fromCharCode=String.fromCharCode;var outOfBoundsChar=fromCharCode(65533);var defaultDecodeEntityOptions={level:"all"};function getDecodedEntity(entity,references,isAttribute,isStrict){var decodeResult=entity;var decodeEntityLastChar=entity[entity.length-1];if(isAttribute&&decodeEntityLastChar==="="){decodeResult=entity}else if(isStrict&&decodeEntityLastChar!==";"){decodeResult=entity}else{var decodeResultByReference=references[entity];if(decodeResultByReference){decodeResult=decodeResultByReference}else if(entity[0]==="&"&&entity[1]==="#"){var decodeSecondChar=entity[2];var decodeCode=decodeSecondChar=="x"||decodeSecondChar=="X"?parseInt(entity.substr(3),16):parseInt(entity.substr(2));decodeResult=decodeCode>=1114111?outOfBoundsChar:decodeCode>65535?surrogate_pairs_1.fromCodePoint(decodeCode):fromCharCode(numeric_unicode_map_1.numericUnicodeMap[decodeCode]||decodeCode)}}return decodeResult}function decodeEntity(entity,_a){var _b=(_a===void 0?defaultDecodeEntityOptions:_a).level,level=_b===void 0?"all":_b;if(!entity){return""}return getDecodedEntity(entity,allNamedReferences[level].entities,false,false)}exports.decodeEntity=decodeEntity;function decode(text,_a){var _b=_a===void 0?defaultDecodeOptions:_a,_c=_b.level,level=_c===void 0?"all":_c,_d=_b.scope,scope=_d===void 0?level==="xml"?"strict":"body":_d;if(!text){return""}var decodeRegExp=decodeRegExps[level][scope];var references=allNamedReferences[level].entities;var isAttribute=scope==="attribute";var isStrict=scope==="strict";return text.replace(decodeRegExp,(function(entity){return getDecodedEntity(entity,references,isAttribute,isStrict)}))}exports.decode=decode; //# sourceMappingURL=./index.js.map /***/ }), diff --git a/cspell.json b/cspell.json index b03d9b793f0e..7a2302c2bde1 100644 --- a/cspell.json +++ b/cspell.json @@ -57,6 +57,7 @@ "Bronn", "Buildscript", "Bunq", + "bunfig", "Bushwick", "CARDFROZEN", "CARDUNFROZEN", diff --git a/package-lock.json b/package-lock.json index cdc94b49d8a0..244694ff1af9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -174,6 +174,7 @@ "@octokit/core": "4.0.4", "@octokit/plugin-paginate-rest": "3.1.0", "@octokit/plugin-throttling": "4.1.0", + "@octokit/request-error": "3.0.3", "@octokit/webhooks-types": "^7.5.1", "@react-native-community/cli": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", @@ -11984,6 +11985,8 @@ }, "node_modules/@octokit/request-error": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", + "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a532f1cd7c4d..2e90285bd570 100644 --- a/package.json +++ b/package.json @@ -249,6 +249,7 @@ "@octokit/core": "4.0.4", "@octokit/plugin-paginate-rest": "3.1.0", "@octokit/plugin-throttling": "4.1.0", + "@octokit/request-error": "3.0.3", "@octokit/webhooks-types": "^7.5.1", "@react-native-community/cli": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", diff --git a/tests/tooling/DeployChecklistUtils.test.ts b/tests/tooling/DeployChecklistUtils.test.ts index 369b73330701..115e2f9dd9eb 100644 --- a/tests/tooling/DeployChecklistUtils.test.ts +++ b/tests/tooling/DeployChecklistUtils.test.ts @@ -24,14 +24,14 @@ let internalOctokit: InternalOctokit; /** * Runs `operation` with fake timers so its retry backoff resolves instantly, advancing the clock by exactly - * `expectedBackoffsMs` in order. Advancing by the exact delays rather than some arbitrarily large amount keeps + * `expectedDelaysMs` in order. Advancing by the exact delays rather than some arbitrarily large amount keeps * these tests pinned to LIST_RETRY_DELAYS_MS: lengthen a delay there and the operation never settles. * * Bun only exposes a synchronous `jest.advanceTimersByTime`, and the code under test schedules each backoff timer * from a `catch` block - i.e. several microtasks after the call starts - so yield until that timer exists before * firing it. */ -async function runWithFakeTimers(operation: () => Promise, expectedBackoffsMs: number[]): Promise { +async function runWithFakeTimers(operation: () => Promise, expectedDelaysMs: number[]): Promise { try { jest.useFakeTimers(); let isSettled = false; @@ -43,18 +43,18 @@ async function runWithFakeTimers(operation: () => Promise, expectedBackoff // doesn't trip Bun's unhandled-rejection reporting in the meantime. pending.catch(() => {}); - for (const backoffMs of expectedBackoffsMs) { + for (const delayMs of expectedDelaysMs) { for (let i = 0; jest.getTimerCount() === 0 && !isSettled && i < 100; i++) { await Promise.resolve(); } - jest.advanceTimersByTime(backoffMs); + jest.advanceTimersByTime(delayMs); } for (let i = 0; !isSettled && i < 100; i++) { await Promise.resolve(); } if (!isSettled) { - throw new Error(`Operation did not settle after advancing the clock by ${expectedBackoffsMs.join(' + ')}ms; did its retry schedule change?`); + throw new Error(`Operation did not settle after advancing the clock by ${expectedDelaysMs.join(' + ')}ms; did its retry schedule change?`); } return await pending; } finally { From 95ea5359f0785c5dbb46c987188570e0afbe4b85 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 11:52:12 -0700 Subject: [PATCH 14/24] Migrate generateTranslationsTest to bun:test This was the last Jest test whose import graph reached @actions/*, and the one blocking the ESM-only upgrade. It runs unchanged in substance: 40 tests, same assertions. The reason it was left behind earlier was type-checking, not runtime. tests/tooling had its own tsconfig declaring @types/bun, and this file reaches src/ through the script it covers, so the whole app got checked with bun-types - whose global JSX declarations are incompatible with the app's React types. The fix removes that separate project rather than working around it. bun-types/test.d.ts is the single file in the package that declares `bun:test`, and it references nothing else, so the root tsconfig now pulls in just that one file and type-checks tests/tooling alongside everything else with the app's real types. It goes in `files` rather than `include` because `exclude` covers node_modules and would drop it. That means one less tsconfig, one less project in `npm run typecheck`, and no ESLint override for the directory. The trade-off worth knowing: @types/jest's globals are now visible in tests/tooling, so a missing bun:test import can type-check and still fail at runtime. Recorded in tests/tooling/README.md. Migration specifics: - `en` is swapped per test with a fresh mock.module() rather than a getter over a mutable variable: the script imports it as a default binding, which Bun resolves once at link time, so a getter would only ever be read for the first test. - The Git automock becomes spies on the three static methods the script actually calls. `openai` needs no stub at all - under --dry-run the script builds a DummyTranslator and never constructs the client. - test:tooling preloads scripts/stubReactNative.js again, because src/languages/en pulls in react-native, whose Flow syntax Bun can't parse. It is the same stub `bun scripts/generateTranslations.ts` already runs with. tests/tooling is now 23 files and 316 tests, and no Jest test reaches @actions/* or @octokit/* - verified across all 1180 of them. --- config/eslint/eslint.config.mjs | 8 -- package.json | 6 +- tests/tooling/README.md | 13 ++- .../generateTranslations.test.ts} | 80 ++++++++++--------- tests/tooling/setup.ts | 4 +- tests/tooling/tsconfig.json | 11 --- tsconfig.json | 16 ++-- 7 files changed, 61 insertions(+), 77 deletions(-) rename tests/{unit/generateTranslationsTest.ts => tooling/generateTranslations.test.ts} (97%) delete mode 100644 tests/tooling/tsconfig.json diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index 839d443d76f9..ea984395a091 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -700,15 +700,7 @@ const config = defineConfig([ }, { - // Excluded from the root tsconfig.json because they need @types/bun, which conflicts with the @types/jest - // used everywhere else. files: ['tests/tooling/**/*.ts'], - languageOptions: { - parserOptions: { - project: path.resolve(projectRoot, 'tests/tooling/tsconfig.json'), - projectService: false, - }, - }, rules: { // bun-types declares `expect(...).resolves`/`.rejects` matchers as returning `void` even though Bun's // own docs recommend (and its runtime requires) awaiting them, so this rule reports every correct use diff --git a/package.json b/package.json index ccdb014ce9ee..ff373d828f3a 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "test:verbose": "TZ=utc NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" JEST_VERBOSE=true jest", "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand", "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", - "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/victory-chart-renderer/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p tests/tooling/tsconfig.json", - "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/tsconfig.json --incremental --tsBuildInfoFile server/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/victory-chart-renderer/tsconfig.json --incremental --tsBuildInfoFile server/victory-chart-renderer/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p tests/tooling/tsconfig.json --incremental --tsBuildInfoFile tests/tooling/tsconfig.tsgo.tsbuildinfo", + "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/victory-chart-renderer/tsconfig.json", + "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/tsconfig.json --incremental --tsBuildInfoFile server/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/victory-chart-renderer/tsconfig.json --incremental --tsBuildInfoFile server/victory-chart-renderer/tsconfig.tsgo.tsbuildinfo", "lint": "./scripts/lint.sh", "lint-changed": "./scripts/lintChanged.sh", "lint-watch": "onchange '**/*.{js,jsx,ts,tsx,mjs,cjs}' -- ./scripts/lint.sh {{changed}}", @@ -82,7 +82,7 @@ "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer --", "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer --", "test:bun": "bun test --concurrent --max-concurrency 7", - "test:tooling": "TZ=utc bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling", + "test:tooling": "TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling", "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer --", "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer --", "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer --" diff --git a/tests/tooling/README.md b/tests/tooling/README.md index 4576d951b616..554788377b28 100644 --- a/tests/tooling/README.md +++ b/tests/tooling/README.md @@ -62,8 +62,13 @@ Bun's `jest` object is close to Jest's but not identical. The gaps that come up Because `mock.module()` is hoisting-sensitive, files that use it import the module under test with a top-level `await import(...)` placed after the mock. -## Shared helpers +## Type-checking -Anything these files import from `tests/utils/` gets pulled into `tests/tooling/tsconfig.json` as well as the -root config, so it is type-checked once with `@types/jest` and once with `@types/bun`. Only import helpers from -there that use neither runner's globals — `createMock` is the one in use today. +These files are type-checked by the root `tsconfig.json` along with everything else, so they see the app's real +types. `bun:test` resolves because that config pulls in `node_modules/bun-types/test.d.ts` — the one file in +bun-types that declares the module — through `files` rather than `include`, since `exclude` covers node_modules. + +The rest of bun-types is deliberately left out: its global JSX declarations are incompatible with the app's React +types, and `generateTranslations.test.ts` reaches `src/` through the script it covers. One consequence is that +`@types/jest`'s globals are visible here too, so a missing `bun:test` import can type-check but still fail at +runtime — import every helper you use. diff --git a/tests/unit/generateTranslationsTest.ts b/tests/tooling/generateTranslations.test.ts similarity index 97% rename from tests/unit/generateTranslationsTest.ts rename to tests/tooling/generateTranslations.test.ts index 2a95c69b4d8c..900cc03668f4 100644 --- a/tests/unit/generateTranslationsTest.ts +++ b/tests/tooling/generateTranslations.test.ts @@ -1,41 +1,43 @@ -import generateTranslations, {GENERATED_FILE_PREFIX} from '@scripts/generateTranslations'; +import {afterAll, afterEach, beforeEach, describe, expect, it, jest, mock} from 'bun:test'; +import type {Mock} from 'bun:test'; + import Git from '@scripts/utils/Git'; import DummyTranslator from '@scripts/utils/Translator/DummyTranslator'; import Translator from '@scripts/utils/Translator/Translator'; -/** - * @jest-environment node - */ import {Str} from 'expensify-common'; import fs from 'fs'; import os from 'os'; import path from 'path'; -let processExitSpy: jest.SpyInstance; -let consoleErrorSpy: jest.SpyInstance; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment -let mockEn: any = jest.requireActual('@src/languages/en'); -jest.mock('@src/languages/en', () => ({ - __esModule: true, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - get default() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return mockEn; - }, -})); -jest.mock('openai'); -jest.mock('@scripts/utils/Git'); - -// Mock Git methods -const mockIsValidRef = jest.fn, Parameters>(); -const mockDiff = jest.fn, Parameters>(); -const mockShow = jest.fn, Parameters>(); - -// Apply mocks to Git using jest.spyOn (ignore type errors for now) -jest.spyOn(Git, 'isValidRef').mockImplementation(mockIsValidRef); -jest.spyOn(Git, 'diff').mockImplementation(mockDiff); -jest.spyOn(Git, 'show').mockImplementation(mockShow); +let processExitSpy: Mock; +let consoleErrorSpy: Mock; + +/** + * Swaps the `en` strings the script reads as its source of truth. Re-mocking per call rather than reading a mutable + * variable through a getter: `generateTranslations` imports `en` as a default binding, which Bun resolves once at + * link time, so a getter would only ever be read for the first test. + * + * The first call has to happen before `generateTranslations` is imported below, because mock.module patches the + * shared module registry entry and existing bindings only pick that up if the patch lands first. `bun test + * --isolate` keeps the replacement from reaching the other files in tests/tooling. + */ +async function setMockEn(strings: unknown) { + await mock.module('@src/languages/en', () => ({default: strings})); +} + +await setMockEn((await import('@src/languages/en')).default); + +// Must be imported after setMockEn above so it reads the mocked `en`. +const {default: generateTranslations, GENERATED_FILE_PREFIX} = await import('@scripts/generateTranslations'); + +// `Git` is a class of static methods, so the three the script calls can be spied on directly. Its remaining +// methods are left real: the script never reaches them under --dry-run, and stubbing them would only hide it if +// that changed. `openai` needs no stub either - the script builds a DummyTranslator under --dry-run and never +// constructs the OpenAI client. +const mockIsValidRef = jest.spyOn(Git, 'isValidRef'); +const mockDiff = jest.spyOn(Git, 'diff'); +const mockShow = jest.spyOn(Git, 'show'); let tempDir: string; let LANGUAGES_DIR: string; @@ -640,7 +642,7 @@ describe('generateTranslations', () => { network: 'Network error', }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -716,7 +718,7 @@ describe('generateTranslations', () => { }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -786,7 +788,7 @@ describe('generateTranslations', () => { save: 'Save', }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -842,7 +844,7 @@ describe('generateTranslations', () => { save: 'Save', }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -894,7 +896,7 @@ describe('generateTranslations', () => { generic: 'An error occurred', }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -960,7 +962,7 @@ describe('generateTranslations', () => { }, simpleTemplate: (name: string) => `Welcome ${name} to our app`, }; - mockEn = strings; + await setMockEn(strings); // Create English source file fs.writeFileSync( @@ -1577,7 +1579,7 @@ describe('generateTranslations', () => { }, }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -1645,7 +1647,7 @@ describe('generateTranslations', () => { }, }, }; - mockEn = strings; + await setMockEn(strings); fs.writeFileSync( EN_PATH, @@ -1860,7 +1862,7 @@ describe('generateTranslations', () => { pin: 'Pin', alsoUnchanged: 'Also unchanged', }; - mockEn = strings; + await setMockEn(strings); // Create English source without context annotation fs.writeFileSync( @@ -2270,7 +2272,7 @@ describe('generateTranslations', () => { }); describe('error summary', () => { - let consoleLogSpy: jest.SpyInstance; + let consoleLogSpy: Mock; beforeEach(() => { consoleLogSpy = jest.spyOn(console, 'log'); diff --git a/tests/tooling/setup.ts b/tests/tooling/setup.ts index b81a5502c6c9..4a9329b37cb0 100644 --- a/tests/tooling/setup.ts +++ b/tests/tooling/setup.ts @@ -6,8 +6,8 @@ import {jest} from 'bun:test'; // GitHub Actions always sets GITHUB_REPOSITORY in CI, but local runs need a default, mirroring jest/setup.ts's // equivalent fallback for the test files Jest still owns. if (!('GITHUB_REPOSITORY' in process.env)) { - process.env.GITHUB_REPOSITORY_OWNER = 'Expensify'; - process.env.GITHUB_REPOSITORY = 'Expensify/App'; + (process.env as NodeJS.ProcessEnv).GITHUB_REPOSITORY_OWNER = 'Expensify'; + (process.env as NodeJS.ProcessEnv).GITHUB_REPOSITORY = 'Expensify/App'; } // The code under test logs heavily, which drowns out the actual results. Jest's CI runs pass --silent for the same diff --git a/tests/tooling/tsconfig.json b/tests/tooling/tsconfig.json deleted file mode 100644 index 13d197938b38..000000000000 --- a/tests/tooling/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - // bun:test's ambient types, which conflict with the `jest` types the root config uses everywhere else. - "types": ["@types/bun"] - }, - "include": ["./**/*.ts"], - // Reset the parent's `exclude`, which excludes this directory (see the root tsconfig.json) so these files - // aren't checked twice with the wrong `types`. - "exclude": ["../../**/node_modules/*"] -} diff --git a/tsconfig.json b/tsconfig.json index 3e7670f2cd64..222376b2fde6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,11 @@ "tests/*": ["./tests/*"] } }, + // bun:test's module declaration, for tests/tooling. Referenced through `files` rather than `include` + // because `exclude` below covers node_modules and would otherwise drop it. This is the one file in bun-types + // that declares the module; the package's other declarations (notably its global JSX namespace) are + // deliberately not pulled in, as they conflict with the app's React types. + "files": ["node_modules/bun-types/test.d.ts"], "include": [ "src", "web", @@ -43,14 +48,5 @@ "**/*.nitro/*.ts", "**/*.nitro/*.tsx" ], - "exclude": [ - "**/node_modules/*", - "**/dist/*", - ".github/actions/**/index.js", - "**/docs/*", - ".claude/worktrees/**", - // These import `bun:test` and are type-checked by tests/tooling/tsconfig.json instead, which declares - // @types/bun — bun:test's ambient types aren't compatible with this config's `jest` types. - "tests/tooling/**" - ] + "exclude": ["**/node_modules/*", "**/dist/*", ".github/actions/**/index.js", "**/docs/*", ".claude/worktrees/**"] } From 9421060b310d544b318abaa90fb5b4f880eef864 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 12:30:29 -0700 Subject: [PATCH 15/24] Fold the tooling tests into test:bun instead of a second script test:bun and test:tooling were two npm scripts, two CI jobs and two Bun invocations for what is one thing: the tests this repo runs under Bun. They are now a single invocation passing both roots, so `npm run test:bun` covers server/ and tests/tooling/ together - 348 tests across 27 files. Two flags had to be reconciled to make one invocation viable, and both resolve in the tooling suite's favour: - --isolate replaces --concurrent. CIGitLogic's tests build on each other's git state and its own comment says it must not run in parallel with anything else, so concurrency was never safe for it. The server suite loses nothing measurable: 32 tests, 3.4s either way. - The tooling preloads now apply to the server tests too. Checked that they are inert there: no server test reads GITHUB_REPOSITORY, and none spies on or asserts console, which is what setup.ts stubs. This also fixes the problem the split CI jobs were working around. The concern was that a server failure would short-circuit the tooling step so it never ran; with one invocation Bun runs every file and reports every failure regardless, which is what the split was trying to buy. So the second job goes away as well. Running just the tooling directory, or one file, means passing the flags directly - documented in tests/tooling/README.md along with the reason each one is load-bearing. --- .github/workflows/bunTests.yml | 15 --------------- CLAUDE.md | 6 +++--- README.md | 2 +- bunfig.toml | 4 ++-- jest.config.js | 2 +- package.json | 3 +-- tests/tooling/README.md | 27 +++++++++++++++++++-------- tests/tooling/setup.ts | 2 +- 8 files changed, 28 insertions(+), 33 deletions(-) diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml index 7257de8a4c8a..22e4047f025b 100644 --- a/.github/workflows/bunTests.yml +++ b/.github/workflows/bunTests.yml @@ -43,18 +43,3 @@ jobs: - name: Smoke-test compiled victory-chart-renderer binary run: ./server/victory-chart-renderer/dist/victory-chart-renderer-linux-x64 --chart-xml "$(cat server/victory-chart-renderer/tests/fixtures/top-categories-6.xml)" --out /tmp/vcr-smoke.png && test -s /tmp/vcr-smoke.png - - # A separate job so a server-test failure doesn't hide the tooling results, and so the git-heavy tooling suite - # runs alongside rather than after the victory-chart-renderer build. - tooling: - name: Tooling tests - runs-on: blacksmith-8vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Run tooling tests - run: npm run test:tooling diff --git a/CLAUDE.md b/CLAUDE.md index 8cd7a0b90483..b0feff267b64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,7 +233,7 @@ React Compiler auto-memoizes object literals, callbacks, JSX, and derived values ### Testing - **Unit Tests**: Jest with React Native Testing Library. Tests for `.github/` and `scripts/` live in - `tests/tooling/` and run under `bun:test` (`npm run test:tooling`) — see `tests/tooling/README.md`. + `tests/tooling/` and run under `bun:test` (`npm run test:bun`) — see `tests/tooling/README.md`. - **Performance Tests**: Reassure framework ## Special Considerations @@ -295,8 +295,8 @@ npm run fmt # Testing npm run test -# Testing the repo's own tooling (.github/ and scripts/) -npm run test:tooling +# Bun tests: server/ plus the repo's own tooling (.github/ and scripts/) +npm run test:bun ``` ### Platform Builds diff --git a/README.md b/README.md index b41454f8b68a..e69813155bb0 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Often times in order to write a unit test, you may need to mock data, a componen to help run our Unit tests. * To run the **Jest unit tests**: `npm run test` -* To run the **tooling tests** (`.github/` and `scripts/`, which use `bun:test`): `npm run test:tooling` — see [tests/tooling/README.md](tests/tooling/README.md) +* To run the **Bun tests** — `server/` plus the `.github/` and `scripts/` tooling tests: `npm run test:bun` — see [tests/tooling/README.md](tests/tooling/README.md) * UI tests guidelines can be found [here](tests/ui/README.md) ## Performance tests diff --git a/bunfig.toml b/bunfig.toml index a3f50ed01418..d9f56587ac0c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,8 +2,8 @@ # `import` and `require()`, so scripts don't load the real Flow-typed RN packages at runtime). preload = ["./scripts/stubReactNative.js"] -# Where a bare `bun test` looks. tests/tooling/ is deliberately excluded: it needs the --isolate and --preload -# flags in the `test:tooling` npm script, so it must be invoked through that (see tests/tooling/README.md). +# Where a bare `bun test` looks. Both suites are actually run through the `test:bun` npm script, which passes +# their roots explicitly along with the --isolate and --preload flags they need (see tests/tooling/README.md). [test] root = "server" pathIgnorePatterns = ["**/fixtures/**", "**/dist/**", "**/.dev/**", "**/__output__/**", "**/__golden__/**"] diff --git a/jest.config.js b/jest.config.js index 0ef4f2bd8fe5..7b9a523b566f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,7 +26,7 @@ module.exports = { // Prevent Babel from transforming worklets in this file so they are treated as normal functions, otherwise FormatSelectionUtilsTest won't run. '/node_modules/@expensify/react-native-live-markdown/lib/commonjs/parseExpensiMark.js', ], - // tests/tooling/ covers .github/ and scripts/ and runs under `bun test` instead (see the `test:tooling` npm + // tests/tooling/ covers .github/ and scripts/ and runs under `bun test` instead (see the `test:bun` npm // script), so those files import `bun:test` rather than Jest's globals. They aren't in testMatch above, and // this keeps them out even if a future testMatch entry broadens to all of tests/. testPathIgnorePatterns: ['/node_modules', '/tests/tooling/'], diff --git a/package.json b/package.json index ff373d828f3a..d244d0dcc147 100644 --- a/package.json +++ b/package.json @@ -81,8 +81,7 @@ "compress-svg": "bun scripts/compressSvg.ts --dir assets/images && bun scripts/compressSvg.ts --dir docs/assets/images", "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer --", "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer --", - "test:bun": "bun test --concurrent --max-concurrency 7", - "test:tooling": "TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling", + "test:bun": "TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./server ./tests/tooling", "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer --", "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer --", "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer --" diff --git a/tests/tooling/README.md b/tests/tooling/README.md index 554788377b28..b3d60161ab92 100644 --- a/tests/tooling/README.md +++ b/tests/tooling/README.md @@ -6,25 +6,36 @@ Tests for the code that builds, deploys and lints this repo — `.github/actions ## Running them +These run as part of `test:bun`, alongside the `server/` suite — one Bun invocation covering both roots. + ```sh -# The whole directory (this is what CI runs) -npm run test:tooling +# Everything Bun runs (this is what CI runs) +npm run test:bun + +# Just this directory +TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling -# One file — pass the flags yourself, the npm script can't forward a path -TZ=utc bun test --isolate --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts +# One file +TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts # One test, by name -npm run test:tooling -- -t 'getPullRequestNumberFromURL' +npm run test:bun -- -t 'getPullRequestNumberFromURL' # Show the output the code under test writes (silenced by default, see setup.ts) -TEST_VERBOSE=true npm run test:tooling +TEST_VERBOSE=true npm run test:bun ``` The leading `./` on a file path is required: without it Bun treats the argument as a name filter and finds nothing, because `bunfig.toml` points bare `bun test` at `server/`. -`--isolate` is not optional. Several files replace `fs`, `child_process` or a shared lib with `mock.module()`, -and Bun shares one module registry across files unless each gets its own. +Neither flag is optional: + +- `--isolate` — several files replace `fs`, `child_process` or a shared lib with `mock.module()`, and Bun shares + one module registry across files unless each gets its own. It also keeps `CIGitLogic`, whose tests build on each + other's git state, from running concurrently with anything else. +- `--preload ./scripts/stubReactNative.js` — `generateTranslations.test.ts` reaches `src/languages/en`, which pulls + in `react-native`, whose Flow syntax Bun can't parse. `bunfig.toml`'s top-level `preload` does not apply to + `bun test`, so it has to be passed here. It's the same stub `bun scripts/generateTranslations.ts` runs with. ## Why bun:test and not Jest diff --git a/tests/tooling/setup.ts b/tests/tooling/setup.ts index 4a9329b37cb0..aff7250d3bde 100644 --- a/tests/tooling/setup.ts +++ b/tests/tooling/setup.ts @@ -1,4 +1,4 @@ -// Preloaded via `bun test --preload` (see the `test:tooling` npm script), once per test file because --isolate +// Preloaded via `bun test --preload` (see the `test:bun` npm script), once per test file because --isolate // gives each file its own globals. Always run this directory through that script: several files replace `fs` or // `child_process` with mock.module(), which without --isolate would reach every file that runs after them. import {jest} from 'bun:test'; From ba0fcf9c708e1bb3d9a4f9562d9a3d0b836e4578 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 13:08:44 -0700 Subject: [PATCH 16/24] Give CIGitLogic a throw-away sandbox and run test files in parallel CIGitLogic built its dummy repo and git remote at fixed paths under $HOME (~/DumDumRepo and ~/dummyGitRemotes/DumDumRepo), and every checkoutRepo() rm -rf'd them. That made the suite hostile to anything else on the machine, which its own header comment admitted: "They should not be run in parallel with other tests on the same machine." It now mkdtemp's a sandbox per run and puts both the remote and the checkout inside it, so two runs cannot see each other. Verified: two concurrent copies of the file both pass, where before they scored 0/9 and 0/1. Two concurrent `npm run test:bun` runs also both pass 348/348. It no longer leaves anything in $HOME either. The suite still changes the process-wide cwd, because the git helpers it covers resolve `git` against process.cwd() exactly as they do in a real Actions checkout - narrowing that would mean adding a cwd parameter to production code purely for the test. Instead the file gets its own process, via --parallel. test:bun therefore moves from --isolate to --parallel, which runs each file in a worker process and implies --isolate, so the module-registry and process.env isolation the suite depends on is retained. Measured back to back on the full 348 tests: 70s -> 54s, then 77s -> 68s on a second pass. The win is capped because wall clock is now set by the longest single file, and CIGitLogic is ~52s of it. --concurrent stays off, and the earlier claim that --isolate forced that choice was wrong - they compose fine. It is off because it does not work here and does not pay: - It makes tests within a file run at once, which cannot beat the longest-file bound above. - 16 of the 23 files reset module-level spies in beforeEach, so a concurrent sibling clears the mocks a running test is about to assert on. 9 tests fail deterministically today; the rest are latent. - Measured slower anyway: --parallel 52.9s passing vs --parallel --concurrent --max-concurrency 7 at 57.2s with 9 failures. CIGitLogic is marked describe.serial regardless, since its tests each build on the repo state the previous one left behind - that ordering is a real invariant, not a flag-dependent one. Also drops a stale README paragraph claiming tests/unit/generateTranslationsTest.ts is still on Jest; that file was migrated and no longer exists. --- bunfig.toml | 2 +- package.json | 2 +- tests/tooling/CIGitLogic.test.ts | 28 +++++++++++++++++----------- tests/tooling/README.md | 29 ++++++++++++++--------------- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/bunfig.toml b/bunfig.toml index d9f56587ac0c..db27eb44682b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -3,7 +3,7 @@ preload = ["./scripts/stubReactNative.js"] # Where a bare `bun test` looks. Both suites are actually run through the `test:bun` npm script, which passes -# their roots explicitly along with the --isolate and --preload flags they need (see tests/tooling/README.md). +# their roots explicitly along with the --parallel and --preload flags they need (see tests/tooling/README.md). [test] root = "server" pathIgnorePatterns = ["**/fixtures/**", "**/dist/**", "**/.dev/**", "**/__output__/**", "**/__golden__/**"] diff --git a/package.json b/package.json index d244d0dcc147..63ba1835a750 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "compress-svg": "bun scripts/compressSvg.ts --dir assets/images && bun scripts/compressSvg.ts --dir docs/assets/images", "server:vcr:dev": "npm run dev -w @expensify/victory-chart-renderer --", "server:vcr:test": "npm run test -w @expensify/victory-chart-renderer --", - "test:bun": "TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./server ./tests/tooling", + "test:bun": "TZ=utc bun test --parallel --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./server ./tests/tooling", "server:vcr:build:linux": "npm run build:linux -w @expensify/victory-chart-renderer --", "server:vcr:build:linux-arm": "npm run build:linux-arm -w @expensify/victory-chart-renderer --", "server:vcr:build:macos": "npm run build:macos -w @expensify/victory-chart-renderer --" diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index 1c9e8720e2fa..05c79e02d94e 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -16,8 +16,14 @@ import path from 'path'; import * as Log from '../../scripts/utils/Logger'; import createMock from '../utils/createMock'; -const DUMMY_DIR = path.resolve(os.homedir(), 'DumDumRepo'); -const GIT_REMOTE = path.resolve(os.homedir(), 'dummyGitRemotes/DumDumRepo'); +// Every run gets its own throw-away sandbox, so nothing on the machine is shared: this suite can run +// alongside other test files in sibling worker processes, a second copy of itself, or a developer's own +// checkout, without any of them fighting over the same directory. +// os.tmpdir() is resolved because on macOS it is a symlink (/var -> /private/var) and git reports the +// real path, which would make path comparisons against process.cwd() disagree. +const SANDBOX_DIR = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ci-git-logic-')); +const GIT_REMOTE = path.join(SANDBOX_DIR, 'remote'); +const DUMMY_DIR = path.join(SANDBOX_DIR, 'checkout'); // Used to mock the Octokit GithubAPI const mockGetInput = jest.fn<(name: string) => string | undefined>(); @@ -190,10 +196,6 @@ function initGithubAPIMocking() { function initGitServer() { Log.info('Initializing git server...'); - if (fs.existsSync(GIT_REMOTE)) { - Log.info(`${GIT_REMOTE} exists, removing it now...`); - fs.rmSync(GIT_REMOTE, {recursive: true}); - } fs.mkdirSync(GIT_REMOTE, {recursive: true}); process.chdir(GIT_REMOTE); exec('git init -b main'); @@ -467,10 +469,14 @@ async function assertPRsMergedBetween(from: string, to: string, expected: number * including a number of real-world edge cases we have encountered and fixed. * * However, because they are different, there are a few additional "rules" with these tests: - * - They should not be run in parallel with other tests on the same machine. They will not play nicely with other tests. * - The whole suite should be run. Running individual tests from the suite may not work as expected. * - Each test builds on the repo state the previous one left behind, so the first failure cascades into the rest. - * Re-run with `--bail` to see only the first one; Bun has no per-file equivalent. + * That chain is why the suite is `describe.serial`, which pins the order no matter what flags Bun is given. + * Re-run with `--bail` to see only the first failure; Bun has no per-file equivalent. + * - The suite changes the process-wide cwd, because the git helpers it exercises resolve `git` against + * `process.cwd()` exactly as they do in a real GitHub Actions checkout. It is restored in `afterAll`, + * but it does mean this file needs its own process to run truly in parallel with other files (`--parallel`, + * which Bun implements with worker processes, gives it one). */ // These tests shell out to real `git`/`npm` subprocesses many times per test and can exceed the default 5000ms @@ -478,7 +484,7 @@ async function assertPRsMergedBetween(from: string, to: string, expected: number setDefaultTimeout(30000); let startingDir: string; -describe('CIGitLogic', () => { +describe.serial('CIGitLogic', () => { beforeAll(() => { Log.info('Starting setup'); startingDir = process.cwd(); @@ -490,9 +496,9 @@ describe('CIGitLogic', () => { afterAll(() => { jest.restoreAllMocks(); - fs.rmSync(DUMMY_DIR, {recursive: true, force: true}); - fs.rmSync(path.resolve(GIT_REMOTE, '..'), {recursive: true, force: true}); + // Restore the cwd before removing the sandbox, so the process is never left sitting in a deleted directory. process.chdir(startingDir); + fs.rmSync(SANDBOX_DIR, {recursive: true, force: true}); }); test('Merge a pull request while the checklist is unlocked', async () => { diff --git a/tests/tooling/README.md b/tests/tooling/README.md index b3d60161ab92..8b9f440d6a63 100644 --- a/tests/tooling/README.md +++ b/tests/tooling/README.md @@ -13,10 +13,10 @@ These run as part of `test:bun`, alongside the `server/` suite — one Bun invoc npm run test:bun # Just this directory -TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling +TZ=utc bun test --parallel --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling # One file -TZ=utc bun test --isolate --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts +TZ=utc bun test --parallel --preload ./scripts/stubReactNative.js --preload ./tests/tooling/setup.ts ./tests/tooling/GithubUtils.test.ts # One test, by name npm run test:bun -- -t 'getPullRequestNumberFromURL' @@ -30,13 +30,20 @@ nothing, because `bunfig.toml` points bare `bun test` at `server/`. Neither flag is optional: -- `--isolate` — several files replace `fs`, `child_process` or a shared lib with `mock.module()`, and Bun shares - one module registry across files unless each gets its own. It also keeps `CIGitLogic`, whose tests build on each - other's git state, from running concurrently with anything else. +- `--parallel` — runs each file in a worker process, and implies `--isolate`. Isolation is the load-bearing part: + several files replace `fs`, `child_process` or a shared lib with `mock.module()`, and Bun shares one module + registry (and `process.env`) across files unless each gets its own. Separate processes additionally give + `CIGitLogic` its own working directory, which it changes. - `--preload ./scripts/stubReactNative.js` — `generateTranslations.test.ts` reaches `src/languages/en`, which pulls in `react-native`, whose Flow syntax Bun can't parse. `bunfig.toml`'s top-level `preload` does not apply to `bun test`, so it has to be passed here. It's the same stub `bun scripts/generateTranslations.ts` runs with. +`--concurrent` is deliberately not used. It makes the tests *within* each file run at once, which does not help: +under `--parallel` the wall clock is set by the single longest file (`CIGitLogic`, ~52s of the ~53s total), and +that file has to stay ordered. It also breaks tests — 16 of the files here reset module-level spies in +`beforeEach`, so a concurrent sibling clears the mocks a running test is about to assert on. Measured on the full +suite: `--parallel` 52.9s and passing, `--parallel --concurrent --max-concurrency 7` 57.2s with 9 failures. + ## Why bun:test and not Jest `@actions/core` and `@actions/github` are ESM-only from their next majors. Jest resolves them through Babel's @@ -47,16 +54,8 @@ That is also the rule for where a new test belongs: **if its import graph reache it goes here.** Everything else — including scripts that only use `scripts/utils/*` — can stay in `tests/unit/` under Jest. -There is one hard constraint on top of that rule: **nothing here may pull `src/` into its type graph.** This -directory type-checks with `@types/bun`, whose ambient JSX declarations conflict with the app's React and -react-native types, so a file that reaches `src/CONST` or `src/types/onyx` drags ~3,000 app files into this -project and produces thousands of spurious errors. Import the narrowest module that has what you need — -`@src/CONST/LOCALES` is self-contained, `@src/types/onyx/Locale` re-exports the whole `@src/CONST` barrel. - -That constraint is why `tests/unit/generateTranslationsTest.ts` is still on Jest even though the script it covers -reaches `@actions/*`: the script itself imports `src/languages/en`, so its type graph can't be narrowed from the -test side. It needs the pure logic extracted, or its own type-check strategy, before it can move — and it has to -move before `@actions/*` goes ESM-only. +Prefer importing the narrowest module that has what you need — `@src/CONST/LOCALES` is self-contained, whereas +`@src/types/onyx/Locale` re-exports the whole `@src/CONST` barrel and drags a large part of the app in with it. ## Differences from the Jest tests From 5ce6c4cd406934694aeda3197de1e8668fb9670c Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 13:36:22 -0700 Subject: [PATCH 17/24] Run CIGitLogic's git commands through the Bun shell Replaces execSync with Bun's `$`. On the real suite, measured back to back three times: 56.3s -> 49.8s, 56.3s -> 49.3s, 51.5s -> 45.2s, so about 11%. Bun parses and spawns the command itself instead of forking /bin/sh -c for each of the ~1000 git and npm invocations; a microbench of `git rev-parse HEAD` puts the per-call cost at 17.6ms vs 27.5ms. Since test:bun's wall clock is set by this file, the whole suite drops with it. `exec` is now a tagged template forwarding to `$`, which keeps the existing logging and TEST_VERBOSE behaviour while letting Bun escape interpolated values. Manual quoting is gone from the call sites that only needed it to survive the shell: exec(`git commit -m "${content}"`) -> exec`git commit -m ${content}` Quotes are kept where they wrap a message built from literal text and values, since there they read as one message rather than as escaping. The three `git rev-parse --verify` probes were try/catch blocks around a command run only for its exit code, two of them with empty catches. They are now `if (await refExists('staging'))`, using `.nothrow()`. Everything becomes async, because Bun's shell has no sync API. Verified no await was missed by running eslint with no-floating-promises forced on: it reports nothing on the file, and does report a deliberately removed await, so the check is real rather than silently inactive. Type-checking needs one structural change. `$` is a runtime API, so it needs the whole of @types/bun rather than the single bun:test module declaration the root project pulls in. Those types redeclare globals the app already owns - a `jest` namespace that shadows @types/jest's generic signatures, and a `fetch` carrying `preconnect` - and adding them to the root project produces 1088 errors across tests/unit. Scoping the whole of tests/tooling into a bun-typed project does not work either, because generateTranslations.test.ts reaches src/ and that reintroduces the conflict from the other side (2564 errors). So CIGitLogic alone is excluded from the root project and type-checked by tests/tooling/tsconfig.json, mirroring server/tsconfig.json. Verified that project actually checks the file, rather than checking nothing, by introducing a deliberate type error and confirming it is reported. The eslint config points type-aware rules at the same project, likewise verified with a deliberate error. Two concurrent `npm run test:bun` runs still pass 348/348 each, so the sandbox isolation from the previous commit survives the change in how subprocesses are spawned. --- config/eslint/eslint.config.mjs | 12 + cspell.json | 1 + package.json | 4 +- tests/tooling/CIGitLogic.test.ts | 495 ++++++++++++++++--------------- tests/tooling/README.md | 8 + tests/tooling/tsconfig.json | 17 ++ tsconfig.json | 4 +- 7 files changed, 292 insertions(+), 249 deletions(-) create mode 100644 tests/tooling/tsconfig.json diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index ea984395a091..69c9bf8d12c7 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -699,6 +699,18 @@ const config = defineConfig([ }, }, + { + // CIGitLogic is excluded from the root tsconfig because it needs @types/bun, so type-aware rules have to + // be pointed at the project that does own it. See tests/tooling/README.md. + files: ['tests/tooling/CIGitLogic.test.ts'], + languageOptions: { + parserOptions: { + project: path.resolve(projectRoot, 'tests/tooling/tsconfig.json'), + projectService: false, + }, + }, + }, + { files: ['tests/tooling/**/*.ts'], rules: { diff --git a/cspell.json b/cspell.json index 7a2302c2bde1..84e54cfb5bb2 100644 --- a/cspell.json +++ b/cspell.json @@ -251,6 +251,7 @@ "Nonfinancial", "Nonmortgage", "Nonnull", + "nothrow", "Nonstore", "Nonupholstered", "Noto", diff --git a/package.json b/package.json index 63ba1835a750..32404eb49415 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "test:verbose": "TZ=utc NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" JEST_VERBOSE=true jest", "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand", "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", - "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/victory-chart-renderer/tsconfig.json", - "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/tsconfig.json --incremental --tsBuildInfoFile server/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/victory-chart-renderer/tsconfig.json --incremental --tsBuildInfoFile server/victory-chart-renderer/tsconfig.tsgo.tsbuildinfo", + "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc && NODE_OPTIONS=--max_old_space_size=8192 tsc -p tests/tooling/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/tsconfig.json && NODE_OPTIONS=--max_old_space_size=8192 tsc -p server/victory-chart-renderer/tsconfig.json", + "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p tests/tooling/tsconfig.json --incremental --tsBuildInfoFile tests/tooling/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/tsconfig.json --incremental --tsBuildInfoFile server/tsconfig.tsgo.tsbuildinfo && tsgo --noEmit -p server/victory-chart-renderer/tsconfig.json --incremental --tsBuildInfoFile server/victory-chart-renderer/tsconfig.tsgo.tsbuildinfo", "lint": "./scripts/lint.sh", "lint-changed": "./scripts/lintChanged.sh", "lint-watch": "onchange '**/*.{js,jsx,ts,tsx,mjs,cjs}' -- ./scripts/lint.sh {{changed}}", diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index 05c79e02d94e..28e8a23b5a6d 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -8,7 +8,7 @@ import * as VersionUpdater from '@github/libs/versionUpdater'; import type {SemverLevel} from '@github/libs/versionUpdater'; import * as core from '@actions/core'; -import {execSync} from 'child_process'; +import {$} from 'bun'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -31,31 +31,42 @@ type CompareCommitsCommit = NonNullable) { + Log.info(String.raw({raw: strings}, ...values)); try { - Log.info(command); - execSync(command, {stdio: isVerbose ? 'inherit' : 'pipe'}); + return await $(strings, ...values).quiet(!isVerbose); } catch (error) { - const stderr = typeof error === 'object' && error !== null && 'stderr' in error ? error.stderr : undefined; - if ((typeof stderr === 'string' || Buffer.isBuffer(stderr)) && stderr) { - Log.error(stderr.toString()); + if (error instanceof $.ShellError) { + Log.error(error.stderr.toString()); } else { Log.error('Error:', error); } - throw new Error(String(error)); + throw error; } } -function setupGitAsHuman() { +/** Whether a ref resolves in the repo at `process.cwd()`. `nothrow` because a missing ref is an expected answer here, not a failure. */ +async function refExists(ref: string) { + return (await $`git rev-parse --verify ${ref}`.quiet().nothrow()).exitCode === 0; +} + +async function setupGitAsHuman() { Log.info('Switching to human git user'); - exec('git config --local user.name test'); - exec('git config --local user.email test@test.com'); + await exec`git config --local user.name test`; + await exec`git config --local user.email test@test.com`; } -function setupGitAsOSBotify() { +async function setupGitAsOSBotify() { Log.info('Switching to OSBotify git user'); - exec(`git config --local user.name ${CONST.OS_BOTIFY}`); - exec('git config --local user.email infra+osbotify@expensify.com'); + await exec`git config --local user.name ${CONST.OS_BOTIFY}`; + await exec`git config --local user.email infra+osbotify@expensify.com`; } function getVersion(): string { @@ -194,270 +205,262 @@ function initGithubAPIMocking() { GithubUtils.octokit.repos.compareCommits = mockCompareCommits as unknown as typeof GithubUtils.octokit.repos.compareCommits; } -function initGitServer() { +async function initGitServer() { Log.info('Initializing git server...'); fs.mkdirSync(GIT_REMOTE, {recursive: true}); process.chdir(GIT_REMOTE); - exec('git init -b main'); - setupGitAsHuman(); - exec('npm init -y'); - exec('npm version --no-git-tag-version 1.0.0-0'); + await exec`git init -b main`; + await setupGitAsHuman(); + await exec`npm init -y`; + await exec`npm version --no-git-tag-version 1.0.0-0`; fs.appendFileSync('.gitignore', 'node_modules/\n'); - exec('git add -A'); - exec('git commit -m "Initial commit"'); - exec('git switch -c staging'); - exec('git switch -c production'); + await exec`git add -A`; + await exec`git commit -m "Initial commit"`; + await exec`git switch -c staging`; + await exec`git switch -c production`; // Tag the production branch with 1.0.0.0 - exec(`git tag ${getVersion()}`); + await exec`git tag ${getVersion()}`; // Bump version to 2.0.0.0 - bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR, true); - exec('git branch -D staging production'); - exec('git switch -c staging'); - exec('git switch -c production'); - exec(`git tag ${getVersion()}`); - exec(`git switch staging`); - exec('git config --local receive.denyCurrentBranch ignore'); + await bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR, true); + await exec`git branch -D staging production`; + await exec`git switch -c staging`; + await exec`git switch -c production`; + await exec`git tag ${getVersion()}`; + await exec`git switch staging`; + await exec`git config --local receive.denyCurrentBranch ignore`; Log.success(`Initialized git server in ${GIT_REMOTE}`); } -function checkoutRepo() { +async function checkoutRepo() { if (fs.existsSync(DUMMY_DIR)) { Log.warn(`Found existing directory at ${DUMMY_DIR}, deleting it to simulate a fresh checkout...`); fs.rmSync(DUMMY_DIR, {recursive: true}); } fs.mkdirSync(DUMMY_DIR); process.chdir(DUMMY_DIR); - exec('git init'); - exec(`git remote add origin ${GIT_REMOTE}`); - exec('git fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +refs/heads/main:refs/remotes/origin/main'); - exec('git checkout --progress --force -B main refs/remotes/origin/main'); + await exec`git init`; + await exec`git remote add origin ${GIT_REMOTE}`; + await exec`git fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +refs/heads/main:refs/remotes/origin/main`; + await exec`git checkout --progress --force -B main refs/remotes/origin/main`; Log.success('Checked out repo at $DUMMY_DIR!'); } -function bumpVersion(level: SemverLevel, isRemote = false) { +async function bumpVersion(level: SemverLevel, isRemote = false) { Log.info('Bumping version...'); - setupGitAsOSBotify(); - exec('git switch main'); + await setupGitAsOSBotify(); + await exec`git switch main`; const nextVersion = VersionUpdater.incrementVersion(getVersion(), level); - exec(`npm --no-git-tag-version version ${nextVersion}`); - exec('git add package.json'); - exec(`git commit -m "Update version to ${nextVersion}"`); + await exec`npm --no-git-tag-version version ${nextVersion}`; + await exec`git add package.json`; + await exec`git commit -m "Update version to ${nextVersion}"`; if (!isRemote) { - exec('git push origin main'); + await exec`git push origin main`; } Log.success(`Version bumped to ${nextVersion} on main`); } -function updateStagingFromMain() { +async function updateStagingFromMain() { Log.info('Recreating staging from main...'); - exec('git switch main'); - try { - execSync('git rev-parse --verify staging', {stdio: 'ignore'}); - exec('git branch -D staging'); - } catch (e) {} - exec('git switch -c staging'); - exec('git push --force origin staging'); + await exec`git switch main`; + if (await refExists('staging')) { + await exec`git branch -D staging`; + } + await exec`git switch -c staging`; + await exec`git push --force origin staging`; Log.success('Recreated staging from main!'); } -function updateProductionFromStaging() { +async function updateProductionFromStaging() { Log.info('Recreating production from staging...'); - try { - execSync('git rev-parse --verify staging', {stdio: 'ignore'}); - } catch (e) { - exec('git fetch origin staging --depth=1'); + if (!(await refExists('staging'))) { + await exec`git fetch origin staging --depth=1`; } - exec('git switch staging'); + await exec`git switch staging`; - try { - execSync('git rev-parse --verify production', {stdio: 'ignore'}); - exec('git branch -D production'); - } catch (e) {} + if (await refExists('production')) { + await exec`git branch -D production`; + } - exec('git switch -c production'); - exec(`git tag ${getVersion()}`); - exec('git push --force --tags origin production'); + await exec`git switch -c production`; + await exec`git tag ${getVersion()}`; + await exec`git push --force --tags origin production`; Log.success('Recreated production from staging!'); } -function createBasicPR(num: number) { +async function createBasicPR(num: number) { const branchName = `pr-${num}`; const content = `Changes from PR #${num}`; const filePath = path.resolve(process.cwd(), `PR${num}.txt`); Log.info(`Creating PR #${num}`); - checkoutRepo(); - setupGitAsHuman(); - exec('git pull'); - exec(`git switch -c ${branchName}`); + await checkoutRepo(); + await setupGitAsHuman(); + await exec`git pull`; + await exec`git switch -c ${branchName}`; fs.appendFileSync(filePath, content); - exec(`git add ${filePath}`); - exec(`git commit -m "${content}"`); + await exec`git add ${filePath}`; + await exec`git commit -m ${content}`; Log.success(`Created PR #${num} in branch ${branchName}`); } -function mergePR(num: number) { +async function mergePR(num: number) { const branchName = `pr-${num}`; Log.info(`Merging PR #${num} to main`); - exec('git switch main'); - exec(`git merge ${branchName} --no-ff -m "Merge pull request #${num} from Expensify/${branchName}"`); - exec('git push origin main'); - exec(`git branch -d ${branchName}`); + await exec`git switch main`; + await exec`git merge ${branchName} --no-ff -m "Merge pull request #${num} from Expensify/${branchName}"`; + await exec`git push origin main`; + await exec`git branch -d ${branchName}`; Log.success(`Merged PR #${num} to main`); } -function cherryPickPRToStaging(num: number, resolveVersionBumpConflicts: () => void = () => {}, resolveMergeCommitConflicts: () => void = () => {}) { +async function cherryPickPRToStaging(num: number, resolveVersionBumpConflicts: () => Promise = async () => {}, resolveMergeCommitConflicts: () => Promise = async () => {}) { Log.info(`Cherry-picking PR ${num} to staging...`); - const prMergeCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim(); - bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD); - const versionBumpCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim(); - checkoutRepo(); - setupGitAsOSBotify(); + const prMergeCommit = (await $`git rev-parse HEAD`.text()).trim(); + await bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD); + const versionBumpCommit = (await $`git rev-parse HEAD`.text()).trim(); + await checkoutRepo(); + await setupGitAsOSBotify(); mockGetInput.mockReturnValue(VersionUpdater.SEMANTIC_VERSION_LEVELS.PATCH); const previousPatchVersion = getPreviousVersion(); // --shallow-exclude is used to speed up the fetch - exec(`git fetch origin main staging --no-tags --shallow-exclude="${previousPatchVersion}"`); + await exec`git fetch origin main staging --no-tags --shallow-exclude=${previousPatchVersion}`; - exec('git switch staging'); - exec('git switch -c cherry-pick-staging'); + await exec`git switch staging`; + await exec`git switch -c cherry-pick-staging`; try { - exec(`git cherry-pick -x --mainline 1 ${versionBumpCommit}`); + await exec`git cherry-pick -x --mainline 1 ${versionBumpCommit}`; } catch (e) { - resolveVersionBumpConflicts(); + await resolveVersionBumpConflicts(); } - setupGitAsHuman(); + await setupGitAsHuman(); try { - exec(`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`); + await exec`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`; } catch (e) { - resolveMergeCommitConflicts(); + await resolveMergeCommitConflicts(); } - setupGitAsOSBotify(); - exec('git switch staging'); - exec(`git merge cherry-pick-staging --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-staging"`); - exec('git branch -d cherry-pick-staging'); - exec('git push origin staging'); + await setupGitAsOSBotify(); + await exec`git switch staging`; + await exec`git merge cherry-pick-staging --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-staging"`; + await exec`git branch -d cherry-pick-staging`; + await exec`git push origin staging`; Log.info(`Merged PR #${num + 1} into staging`); - tagStaging(); + await tagStaging(); Log.success(`Successfully cherry-picked PR #${num} to staging!`); } -function cherryPickPRToProduction(num: number, resolveVersionBumpConflicts: () => void = () => {}, resolveMergeCommitConflicts: () => void = () => {}) { +async function cherryPickPRToProduction(num: number, resolveVersionBumpConflicts: () => Promise = async () => {}, resolveMergeCommitConflicts: () => Promise = async () => {}) { Log.info(`Cherry-picking PR ${num} to production...`); - const prMergeCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim(); - bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.PATCH); - let versionBumpCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim(); - checkoutRepo(); - setupGitAsOSBotify(); + const prMergeCommit = (await $`git rev-parse HEAD`.text()).trim(); + await bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.PATCH); + let versionBumpCommit = (await $`git rev-parse HEAD`.text()).trim(); + await checkoutRepo(); + await setupGitAsOSBotify(); mockGetInput.mockReturnValue(VersionUpdater.SEMANTIC_VERSION_LEVELS.MINOR); const previousPatchVersion = getPreviousVersion(); - exec(`git fetch origin main production --no-tags --shallow-exclude="${previousPatchVersion}"`); + await exec`git fetch origin main production --no-tags --shallow-exclude=${previousPatchVersion}`; - exec('git switch production'); - exec('git switch -c cherry-pick-production'); + await exec`git switch production`; + await exec`git switch -c cherry-pick-production`; try { - exec(`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`); + await exec`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`; } catch (e) { - resolveVersionBumpConflicts(); + await resolveVersionBumpConflicts(); } - setupGitAsHuman(); + await setupGitAsHuman(); try { - exec(`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`); + await exec`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`; } catch (e) { - resolveMergeCommitConflicts(); + await resolveMergeCommitConflicts(); } - setupGitAsOSBotify(); - exec('git switch production'); - exec(`git merge cherry-pick-production --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-production"`); - exec('git branch -d cherry-pick-production'); - exec('git push origin production'); + await setupGitAsOSBotify(); + await exec`git switch production`; + await exec`git merge cherry-pick-production --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-production"`; + await exec`git branch -d cherry-pick-production`; + await exec`git push origin production`; Log.info(`Merged PR #${num + 1} into production`); - tagProduction(); - - checkoutRepo(); - bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD); - versionBumpCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim(); - exec(`git fetch origin staging --depth=1`); - exec(`git switch staging`); - exec(`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`); - exec('git push origin staging'); - tagStaging(); + await tagProduction(); + + await checkoutRepo(); + await bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD); + versionBumpCommit = (await $`git rev-parse HEAD`.text()).trim(); + await exec`git fetch origin staging --depth=1`; + await exec`git switch staging`; + await exec`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`; + await exec`git push origin staging`; + await tagStaging(); Log.success(`Pushed to staging after CP to production`); Log.success(`Successfully cherry-picked PR #${num} to production!`); } -function tagStaging() { +async function tagStaging() { Log.info('Tagging new version from the staging branch...'); - checkoutRepo(); - setupGitAsOSBotify(); - try { - execSync('git rev-parse --verify staging', {stdio: 'ignore'}); - } catch (e) { - exec('git fetch origin staging --depth=1'); + await checkoutRepo(); + await setupGitAsOSBotify(); + if (!(await refExists('staging'))) { + await exec`git fetch origin staging --depth=1`; } - exec('git switch staging'); - exec(`git tag ${getVersion()}-staging`); - exec('git push --tags'); + await exec`git switch staging`; + await exec`git tag ${getVersion()}-staging`; + await exec`git push --tags`; Log.success(`Created new tag ${getVersion()}`); } -function tagProduction() { +async function tagProduction() { Log.info('Tagging new version from the production branch...'); Log.info(`Version is: ${getVersion()}`); - checkoutRepo(); - setupGitAsOSBotify(); - try { - execSync('git rev-parse --verify production', {stdio: 'ignore'}); - } catch (e) { - exec('git fetch origin production --depth=1'); + await checkoutRepo(); + await setupGitAsOSBotify(); + if (!(await refExists('production'))) { + await exec`git fetch origin production --depth=1`; } - exec('git switch production'); - exec(`git tag ${getVersion()}`); - exec('git push --tags'); + await exec`git switch production`; + await exec`git tag ${getVersion()}`; + await exec`git push --tags`; Log.success(`Created new tag ${getVersion()}`); } -function deployStaging() { +async function deployStaging() { Log.info('Deploying staging...'); - checkoutRepo(); - bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD); - updateStagingFromMain(); - tagStaging(); + await checkoutRepo(); + await bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD); + await updateStagingFromMain(); + await tagStaging(); Log.success(`Deployed ${getVersion()} to staging!`); } -function deployProduction() { +async function deployProduction() { Log.info('Checklist closed, deploying production and staging...'); Log.info('Deploying production...'); - updateProductionFromStaging(); + await updateProductionFromStaging(); Log.success(`Deployed v${getVersion()} to production!`); Log.info('Deploying staging...'); - bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.PATCH); - updateStagingFromMain(); - tagStaging(); + await bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.PATCH); + await updateStagingFromMain(); + await tagStaging(); Log.success(`Deployed v${getVersion()} to staging!`); } async function assertPRsMergedBetween(from: string, to: string, expected: number[]) { - checkoutRepo(); + await checkoutRepo(); const PRs = await GitUtils.getPullRequestsDeployedBetween(from, to, CONST.APP_REPO); expect(PRs).toStrictEqual(expected); Log.success(`Verified PRs merged between ${from} and ${to} are [${expected.join(',')}]`); @@ -485,12 +488,12 @@ setDefaultTimeout(30000); let startingDir: string; describe.serial('CIGitLogic', () => { - beforeAll(() => { + beforeAll(async () => { Log.info('Starting setup'); startingDir = process.cwd(); - initGitServer(); + await initGitServer(); initGithubAPIMocking(); - checkoutRepo(); + await checkoutRepo(); Log.success('Setup complete!'); }); @@ -502,26 +505,26 @@ describe.serial('CIGitLogic', () => { }); test('Merge a pull request while the checklist is unlocked', async () => { - createBasicPR(1); - mergePR(1); - deployStaging(); + await createBasicPR(1); + await mergePR(1); + await deployStaging(); // Verify output for checklist and deploy comment await assertPRsMergedBetween('2.0.0-0', '2.0.0-1-staging', [1]); }); test("Merge a pull request with the checklist locked, but don't CP it", async () => { - createBasicPR(2); - mergePR(2); + await createBasicPR(2); + await mergePR(2); // Verify output for checklist and deploy comment, and make sure PR #2 is not on staging await assertPRsMergedBetween('2.0.0-0', '2.0.0-1-staging', [1]); }); test('Merge a pull request with the checklist locked and CP it to staging', async () => { - createBasicPR(3); - mergePR(3); - cherryPickPRToStaging(3); + await createBasicPR(3); + await mergePR(3); + await cherryPickPRToStaging(3); // Verify output for checklist await assertPRsMergedBetween('2.0.0-0', '2.0.0-2-staging', [1, 3]); @@ -531,9 +534,9 @@ describe.serial('CIGitLogic', () => { }); test('Merge a pull request with the checklist locked and CP it to production', async () => { - createBasicPR(5); - mergePR(5); - cherryPickPRToProduction(5); + await createBasicPR(5); + await mergePR(5); + await cherryPickPRToProduction(5); // Verify output for checklist await assertPRsMergedBetween('2.0.0-0', '2.0.1-1-staging', [1, 3]); @@ -543,7 +546,7 @@ describe.serial('CIGitLogic', () => { }); test('Close the checklist, deploy production and staging', async () => { - deployProduction(); + await deployProduction(); // Verify output for release body and production deploy comments await assertPRsMergedBetween('2.0.0-0', '2.0.1-1', [1, 3]); @@ -553,9 +556,9 @@ describe.serial('CIGitLogic', () => { }); test('Merging another pull request when the checklist is unlocked', async () => { - createBasicPR(6); - mergePR(6); - deployStaging(); + await createBasicPR(6); + await mergePR(6); + await deployStaging(); // Verify output for checklist await assertPRsMergedBetween('2.0.0-2-staging', '2.0.2-1-staging', [2, 5, 6]); @@ -566,16 +569,16 @@ describe.serial('CIGitLogic', () => { test('Deploying a PR, then CPing a revert, then adding the same code back again before the next production deploy results in the correct code on staging and production', async () => { Log.info('Creating myFile.txt in PR #7'); - setupGitAsHuman(); - exec('git switch main'); - exec('git switch -c pr-7'); + await setupGitAsHuman(); + await exec`git switch main`; + await exec`git switch -c pr-7`; const initialFileContent = 'Changes from PR #7'; fs.appendFileSync('myFile.txt', 'Changes from PR #7'); - exec('git add myFile.txt'); - exec('git commit -m "Add myFile.txt in PR #7"'); + await exec`git add myFile.txt`; + await exec`git commit -m "Add myFile.txt in PR #7"`; - mergePR(7); - deployStaging(); + await mergePR(7); + await deployStaging(); // Verify output for checklist await assertPRsMergedBetween('2.0.0-2-staging', '2.0.2-2-staging', [2, 5, 6, 7]); @@ -584,19 +587,19 @@ describe.serial('CIGitLogic', () => { await assertPRsMergedBetween('2.0.2-1-staging', '2.0.2-2-staging', [7]); Log.info('Appending and prepending content to myFile.txt in PR #8'); - setupGitAsHuman(); - exec('git switch main'); - exec('git switch -c pr-8'); + await setupGitAsHuman(); + await exec`git switch main`; + await exec`git switch -c pr-8`; const newFileContent = ` Prepended content ${initialFileContent} Appended content `; fs.writeFileSync('myFile.txt', newFileContent, {encoding: 'utf-8'}); - exec('git add myFile.txt'); - exec('git commit -m "Append and prepend content in myFile.txt"'); - mergePR(8); - deployStaging(); + await exec`git add myFile.txt`; + await exec`git commit -m "Append and prepend content in myFile.txt"`; + await mergePR(8); + await deployStaging(); // Verify output for checklist await assertPRsMergedBetween('2.0.0-2-staging', '2.0.2-3-staging', [2, 5, 6, 7, 8]); @@ -605,38 +608,38 @@ Appended content await assertPRsMergedBetween('2.0.2-2-staging', '2.0.2-3-staging', [8]); Log.info('Making an unrelated change in PR #9'); - setupGitAsHuman(); - exec('git switch main'); - exec('git switch -c pr-9'); + await setupGitAsHuman(); + await exec`git switch main`; + await exec`git switch -c pr-9`; fs.appendFileSync('anotherFile.txt', 'some content'); - exec('git add anotherFile.txt'); - exec('git commit -m "Create another file"'); - mergePR(9); + await exec`git add anotherFile.txt`; + await exec`git commit -m "Create another file"`; + await mergePR(9); Log.info('Reverting the append + prepend on main in PR #10'); - setupGitAsHuman(); - exec('git switch main'); - exec('git switch -c pr-10'); + await setupGitAsHuman(); + await exec`git switch main`; + await exec`git switch -c pr-10`; fs.writeFileSync('myFile.txt', initialFileContent); - exec('git add myFile.txt'); - exec('git commit -m "Revert append and prepend"'); - mergePR(10); - cherryPickPRToStaging(10); + await exec`git add myFile.txt`; + await exec`git commit -m "Revert append and prepend"`; + await mergePR(10); + await cherryPickPRToStaging(10); Log.info('Verifying that the revert is present on staging, but the unrelated change is not'); expect(fs.readFileSync('myFile.txt', {encoding: 'utf8'})).toBe(initialFileContent); expect(fs.existsSync('anotherFile.txt')).toBe(false); Log.info('Repeating previously reverted append + prepend on main in PR #10'); - setupGitAsHuman(); - exec('git switch main'); - exec('git switch -c pr-11'); + await setupGitAsHuman(); + await exec`git switch main`; + await exec`git switch -c pr-11`; fs.writeFileSync('myFile.txt', newFileContent, {encoding: 'utf-8'}); - exec('git add myFile.txt'); - exec('git commit -m "Append and prepend content in myFile.txt"'); + await exec`git add myFile.txt`; + await exec`git commit -m "Append and prepend content in myFile.txt"`; - mergePR(11); - deployProduction(); + await mergePR(11); + await deployProduction(); // Verify production release list await assertPRsMergedBetween('2.0.1-1', '2.0.2-4', [2, 5, 6, 7, 8, 10]); @@ -646,11 +649,11 @@ Appended content }); test('Force-pushing to a branch after rebasing older commits', async () => { - createBasicPR(12); - exec('git push origin pr-12'); - createBasicPR(13); - mergePR(13); - deployStaging(); + await createBasicPR(12); + await exec`git push origin pr-12`; + await createBasicPR(13); + await mergePR(13); + await deployStaging(); // Verify PRs for checklist await assertPRsMergedBetween('2.0.2-4-staging', '2.0.3-1-staging', [9, 11, 13]); @@ -658,15 +661,15 @@ Appended content // Verify PRs for deploy comments await assertPRsMergedBetween('2.0.3-0-staging', '2.0.3-1-staging', [13]); - checkoutRepo(); - setupGitAsHuman(); - exec('git fetch origin pr-12'); - exec('git switch pr-12'); - exec('git rebase main -Xours'); - exec('git push --force origin pr-12'); - mergePR(12); + await checkoutRepo(); + await setupGitAsHuman(); + await exec`git fetch origin pr-12`; + await exec`git switch pr-12`; + await exec`git rebase main -Xours`; + await exec`git push --force origin pr-12`; + await mergePR(12); - deployProduction(); + await deployProduction(); // Verify PRs for deploy comments / release await assertPRsMergedBetween('2.0.2-4-staging', '2.0.3-1-staging', [9, 11, 13]); @@ -677,50 +680,50 @@ Appended content test('Manual version bump', async () => { Log.info('Creating manual version bump in PR #14'); - checkoutRepo(); - setupGitAsHuman(); - exec('git pull'); - exec('git switch -c "pr-14"'); + await checkoutRepo(); + await setupGitAsHuman(); + await exec`git pull`; + await exec`git switch -c pr-14`; for (let i = 0; i < 3; i++) { - exec(`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`); + await exec`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`; } - exec('git add package.json'); - exec(`git commit -m "Manually bump version to ${getVersion()} in PR #14"`); + await exec`git add package.json`; + await exec`git commit -m "Manually bump version to ${getVersion()} in PR #14"`; Log.success('Created manual version bump in PR #13 in branch pr-14'); - mergePR(14); + await mergePR(14); Log.info('Deploying staging...'); - checkoutRepo(); - updateStagingFromMain(); - tagStaging(); + await checkoutRepo(); + await updateStagingFromMain(); + await tagStaging(); Log.success(`Deployed v${getVersion()} to staging!`); // Verify PRs for deploy comments / release and new checklist await assertPRsMergedBetween('2.0.4-0-staging', '5.0.0-0-staging', [14]); Log.info('Creating manual version bump in PR #15'); - checkoutRepo(); - setupGitAsHuman(); - exec('git pull'); - exec('git switch -c "pr-15"'); + await checkoutRepo(); + await setupGitAsHuman(); + await exec`git pull`; + await exec`git switch -c pr-15`; for (let i = 0; i < 3; i++) { - exec(`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`); + await exec`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`; } - exec('git add package.json'); - exec(`git commit -m "Manually bump version to ${getVersion()} in PR #15"`); + await exec`git add package.json`; + await exec`git commit -m "Manually bump version to ${getVersion()} in PR #15"`; Log.success('Created manual version bump in PR #15 in branch pr-15'); const packageJSONBefore = fs.readFileSync('package.json', {encoding: 'utf-8'}); - mergePR(15); - cherryPickPRToStaging( + await mergePR(15); + await cherryPickPRToStaging( 15, - () => { + async () => { fs.writeFileSync('package.json', packageJSONBefore); - exec('git add package.json'); - exec('git cherry-pick --no-edit --continue'); + await exec`git add package.json`; + await exec`git cherry-pick --no-edit --continue`; }, - () => { - exec('git commit --no-edit --allow-empty'); + async () => { + await exec`git commit --no-edit --allow-empty`; }, ); diff --git a/tests/tooling/README.md b/tests/tooling/README.md index 8b9f440d6a63..0cd544cdaaa4 100644 --- a/tests/tooling/README.md +++ b/tests/tooling/README.md @@ -82,3 +82,11 @@ The rest of bun-types is deliberately left out: its global JSX declarations are types, and `generateTranslations.test.ts` reaches `src/` through the script it covers. One consequence is that `@types/jest`'s globals are visible here too, so a missing `bun:test` import can type-check but still fail at runtime — import every helper you use. + +`CIGitLogic.test.ts` is the exception. It uses Bun's `$` shell, which is a runtime API rather than a module +declaration, so it needs the full `@types/bun`. Those types redeclare globals the app already owns — a `jest` +namespace that shadows `@types/jest`'s generic signatures, and a `fetch` carrying `preconnect` — and adding them +to the root project produces ~1,100 errors across `tests/unit`. So that one file is excluded from the root +project and type-checked by `tests/tooling/tsconfig.json`, which mirrors what `server/tsconfig.json` does. A new +test needing Bun runtime APIs should be added to that project's `files`; one that only needs `bun:test` should +not, so it keeps seeing the app's types. diff --git a/tests/tooling/tsconfig.json b/tests/tooling/tsconfig.json new file mode 100644 index 000000000000..89e7d0f63b6d --- /dev/null +++ b/tests/tooling/tsconfig.json @@ -0,0 +1,17 @@ +{ + // CIGitLogic is the one test here that uses Bun's runtime API — the `$` shell — which needs `@types/bun`. + // Those types redeclare globals that the app's own types already own: a `jest` namespace that shadows + // @types/jest's generic signatures, and a `fetch` carrying `preconnect`. Loading them into the root project + // produces ~1,100 errors across tests/unit, so this project confines them to the single file that needs them. + // Everything else in tests/tooling stays in the root project, where it sees the app's real types. + // + // `include: []` is required: `extends` inherits the root project's `include`, which would otherwise pull the + // whole app in here and reintroduce the same conflict this file exists to avoid. + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["@types/bun"], + "noEmit": true + }, + "include": [], + "files": ["CIGitLogic.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 222376b2fde6..59575a3578f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,5 +48,7 @@ "**/*.nitro/*.ts", "**/*.nitro/*.tsx" ], - "exclude": ["**/node_modules/*", "**/dist/*", ".github/actions/**/index.js", "**/docs/*", ".claude/worktrees/**"] + // CIGitLogic uses Bun's `$` shell, so it needs @types/bun, whose globals conflict with this project's. + // It is type-checked by tests/tooling/tsconfig.json instead — see the comment there. + "exclude": ["**/node_modules/*", "**/dist/*", ".github/actions/**/index.js", "**/docs/*", ".claude/worktrees/**", "tests/tooling/CIGitLogic.test.ts"] } From b2781885a1144a5672957d00c8bb3acd9327bb51 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 15:46:04 -0700 Subject: [PATCH 18/24] Wrap Bun's shell in a Proxy instead of an exec() helper The exec() wrapper was an async function, so it awaited the ShellPromise internally and handed back a plain ShellOutput. That destroyed Bun's chaining, which is why six commands in this file bypassed exec() and called $ directly to reach .text() and .nothrow() - and were therefore never logged. A Proxy over $ keeps the lazy, chainable ShellPromise intact, so one logged $ now covers every command in the file, including the git rev-parse probes. Verified the chaining survives: .text(), .nothrow(), .quiet(), the $.ShellError constructor and instanceof all pass through the Proxy. The failure path is dropped rather than ported. exec() caught ShellError to log stderr, on the assumption Bun would not surface it - that was wrong. bun:test prints the whole error object, stderr included, along with the source line of the failing command: ShellError: Failed with exit code 128 exitCode: 128, stdout: "", stderr: "fatal: not a git repository ...", So the catch block was redundant. It also could not have been kept here: attaching a rejection handler inside the apply trap starts the command eagerly, and the next .quiet() then throws "Shell is already running". Both facts are recorded in the comment so nobody adds one back. Checked that type-aware linting still works through the Proxy, since Reflect.apply returns `any` and could have silently degraded the return type to something no-floating-promises ignores. It does not: with a deliberately removed await, the rule fires on both a bare $`...` and a floating helper call. No performance cost - marginally faster across two pairs (59s -> 54s, 53s -> 48s), consistent with dropping an async frame and a try/catch per call. --- tests/tooling/CIGitLogic.test.ts | 263 +++++++++++++++---------------- 1 file changed, 131 insertions(+), 132 deletions(-) diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index 28e8a23b5a6d..9c67c3cdebf3 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -8,7 +8,7 @@ import * as VersionUpdater from '@github/libs/versionUpdater'; import type {SemverLevel} from '@github/libs/versionUpdater'; import * as core from '@actions/core'; -import {$} from 'bun'; +import {$ as bun$} from 'bun'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -32,41 +32,40 @@ type CompareCommitsCommit = NonNullable) { - Log.info(String.raw({raw: strings}, ...values)); - try { - return await $(strings, ...values).quiet(!isVerbose); - } catch (error) { - if (error instanceof $.ShellError) { - Log.error(error.stderr.toString()); - } else { - Log.error('Error:', error); - } - throw error; - } -} +const $ = new Proxy(bun$, { + apply(target, thisArg, args: [TemplateStringsArray, ...string[]]) { + Log.info(String.raw({raw: args[0]}, ...args.slice(1))); + return Reflect.apply(target, thisArg, args).quiet(!isVerbose); + }, +}); /** Whether a ref resolves in the repo at `process.cwd()`. `nothrow` because a missing ref is an expected answer here, not a failure. */ async function refExists(ref: string) { - return (await $`git rev-parse --verify ${ref}`.quiet().nothrow()).exitCode === 0; + return (await $`git rev-parse --verify ${ref}`.nothrow()).exitCode === 0; } async function setupGitAsHuman() { Log.info('Switching to human git user'); - await exec`git config --local user.name test`; - await exec`git config --local user.email test@test.com`; + await $`git config --local user.name test`; + await $`git config --local user.email test@test.com`; } async function setupGitAsOSBotify() { Log.info('Switching to OSBotify git user'); - await exec`git config --local user.name ${CONST.OS_BOTIFY}`; - await exec`git config --local user.email infra+osbotify@expensify.com`; + await $`git config --local user.name ${CONST.OS_BOTIFY}`; + await $`git config --local user.email infra+osbotify@expensify.com`; } function getVersion(): string { @@ -209,27 +208,27 @@ async function initGitServer() { Log.info('Initializing git server...'); fs.mkdirSync(GIT_REMOTE, {recursive: true}); process.chdir(GIT_REMOTE); - await exec`git init -b main`; + await $`git init -b main`; await setupGitAsHuman(); - await exec`npm init -y`; - await exec`npm version --no-git-tag-version 1.0.0-0`; + await $`npm init -y`; + await $`npm version --no-git-tag-version 1.0.0-0`; fs.appendFileSync('.gitignore', 'node_modules/\n'); - await exec`git add -A`; - await exec`git commit -m "Initial commit"`; - await exec`git switch -c staging`; - await exec`git switch -c production`; + await $`git add -A`; + await $`git commit -m "Initial commit"`; + await $`git switch -c staging`; + await $`git switch -c production`; // Tag the production branch with 1.0.0.0 - await exec`git tag ${getVersion()}`; + await $`git tag ${getVersion()}`; // Bump version to 2.0.0.0 await bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR, true); - await exec`git branch -D staging production`; - await exec`git switch -c staging`; - await exec`git switch -c production`; - await exec`git tag ${getVersion()}`; - await exec`git switch staging`; - await exec`git config --local receive.denyCurrentBranch ignore`; + await $`git branch -D staging production`; + await $`git switch -c staging`; + await $`git switch -c production`; + await $`git tag ${getVersion()}`; + await $`git switch staging`; + await $`git config --local receive.denyCurrentBranch ignore`; Log.success(`Initialized git server in ${GIT_REMOTE}`); } @@ -240,35 +239,35 @@ async function checkoutRepo() { } fs.mkdirSync(DUMMY_DIR); process.chdir(DUMMY_DIR); - await exec`git init`; - await exec`git remote add origin ${GIT_REMOTE}`; - await exec`git fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +refs/heads/main:refs/remotes/origin/main`; - await exec`git checkout --progress --force -B main refs/remotes/origin/main`; + await $`git init`; + await $`git remote add origin ${GIT_REMOTE}`; + await $`git fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +refs/heads/main:refs/remotes/origin/main`; + await $`git checkout --progress --force -B main refs/remotes/origin/main`; Log.success('Checked out repo at $DUMMY_DIR!'); } async function bumpVersion(level: SemverLevel, isRemote = false) { Log.info('Bumping version...'); await setupGitAsOSBotify(); - await exec`git switch main`; + await $`git switch main`; const nextVersion = VersionUpdater.incrementVersion(getVersion(), level); - await exec`npm --no-git-tag-version version ${nextVersion}`; - await exec`git add package.json`; - await exec`git commit -m "Update version to ${nextVersion}"`; + await $`npm --no-git-tag-version version ${nextVersion}`; + await $`git add package.json`; + await $`git commit -m "Update version to ${nextVersion}"`; if (!isRemote) { - await exec`git push origin main`; + await $`git push origin main`; } Log.success(`Version bumped to ${nextVersion} on main`); } async function updateStagingFromMain() { Log.info('Recreating staging from main...'); - await exec`git switch main`; + await $`git switch main`; if (await refExists('staging')) { - await exec`git branch -D staging`; + await $`git branch -D staging`; } - await exec`git switch -c staging`; - await exec`git push --force origin staging`; + await $`git switch -c staging`; + await $`git push --force origin staging`; Log.success('Recreated staging from main!'); } @@ -276,18 +275,18 @@ async function updateProductionFromStaging() { Log.info('Recreating production from staging...'); if (!(await refExists('staging'))) { - await exec`git fetch origin staging --depth=1`; + await $`git fetch origin staging --depth=1`; } - await exec`git switch staging`; + await $`git switch staging`; if (await refExists('production')) { - await exec`git branch -D production`; + await $`git branch -D production`; } - await exec`git switch -c production`; - await exec`git tag ${getVersion()}`; - await exec`git push --force --tags origin production`; + await $`git switch -c production`; + await $`git tag ${getVersion()}`; + await $`git push --force --tags origin production`; Log.success('Recreated production from staging!'); } @@ -299,11 +298,11 @@ async function createBasicPR(num: number) { Log.info(`Creating PR #${num}`); await checkoutRepo(); await setupGitAsHuman(); - await exec`git pull`; - await exec`git switch -c ${branchName}`; + await $`git pull`; + await $`git switch -c ${branchName}`; fs.appendFileSync(filePath, content); - await exec`git add ${filePath}`; - await exec`git commit -m ${content}`; + await $`git add ${filePath}`; + await $`git commit -m ${content}`; Log.success(`Created PR #${num} in branch ${branchName}`); } @@ -311,10 +310,10 @@ async function mergePR(num: number) { const branchName = `pr-${num}`; Log.info(`Merging PR #${num} to main`); - await exec`git switch main`; - await exec`git merge ${branchName} --no-ff -m "Merge pull request #${num} from Expensify/${branchName}"`; - await exec`git push origin main`; - await exec`git branch -d ${branchName}`; + await $`git switch main`; + await $`git merge ${branchName} --no-ff -m "Merge pull request #${num} from Expensify/${branchName}"`; + await $`git push origin main`; + await $`git branch -d ${branchName}`; Log.success(`Merged PR #${num} to main`); } @@ -330,13 +329,13 @@ async function cherryPickPRToStaging(num: number, resolveVersionBumpConflicts: ( const previousPatchVersion = getPreviousVersion(); // --shallow-exclude is used to speed up the fetch - await exec`git fetch origin main staging --no-tags --shallow-exclude=${previousPatchVersion}`; + await $`git fetch origin main staging --no-tags --shallow-exclude=${previousPatchVersion}`; - await exec`git switch staging`; - await exec`git switch -c cherry-pick-staging`; + await $`git switch staging`; + await $`git switch -c cherry-pick-staging`; try { - await exec`git cherry-pick -x --mainline 1 ${versionBumpCommit}`; + await $`git cherry-pick -x --mainline 1 ${versionBumpCommit}`; } catch (e) { await resolveVersionBumpConflicts(); } @@ -344,16 +343,16 @@ async function cherryPickPRToStaging(num: number, resolveVersionBumpConflicts: ( await setupGitAsHuman(); try { - await exec`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`; + await $`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`; } catch (e) { await resolveMergeCommitConflicts(); } await setupGitAsOSBotify(); - await exec`git switch staging`; - await exec`git merge cherry-pick-staging --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-staging"`; - await exec`git branch -d cherry-pick-staging`; - await exec`git push origin staging`; + await $`git switch staging`; + await $`git merge cherry-pick-staging --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-staging"`; + await $`git branch -d cherry-pick-staging`; + await $`git push origin staging`; Log.info(`Merged PR #${num + 1} into staging`); await tagStaging(); Log.success(`Successfully cherry-picked PR #${num} to staging!`); @@ -369,13 +368,13 @@ async function cherryPickPRToProduction(num: number, resolveVersionBumpConflicts mockGetInput.mockReturnValue(VersionUpdater.SEMANTIC_VERSION_LEVELS.MINOR); const previousPatchVersion = getPreviousVersion(); - await exec`git fetch origin main production --no-tags --shallow-exclude=${previousPatchVersion}`; + await $`git fetch origin main production --no-tags --shallow-exclude=${previousPatchVersion}`; - await exec`git switch production`; - await exec`git switch -c cherry-pick-production`; + await $`git switch production`; + await $`git switch -c cherry-pick-production`; try { - await exec`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`; + await $`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`; } catch (e) { await resolveVersionBumpConflicts(); } @@ -383,26 +382,26 @@ async function cherryPickPRToProduction(num: number, resolveVersionBumpConflicts await setupGitAsHuman(); try { - await exec`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`; + await $`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`; } catch (e) { await resolveMergeCommitConflicts(); } await setupGitAsOSBotify(); - await exec`git switch production`; - await exec`git merge cherry-pick-production --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-production"`; - await exec`git branch -d cherry-pick-production`; - await exec`git push origin production`; + await $`git switch production`; + await $`git merge cherry-pick-production --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-production"`; + await $`git branch -d cherry-pick-production`; + await $`git push origin production`; Log.info(`Merged PR #${num + 1} into production`); await tagProduction(); await checkoutRepo(); await bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD); versionBumpCommit = (await $`git rev-parse HEAD`.text()).trim(); - await exec`git fetch origin staging --depth=1`; - await exec`git switch staging`; - await exec`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`; - await exec`git push origin staging`; + await $`git fetch origin staging --depth=1`; + await $`git switch staging`; + await $`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`; + await $`git push origin staging`; await tagStaging(); Log.success(`Pushed to staging after CP to production`); @@ -414,11 +413,11 @@ async function tagStaging() { await checkoutRepo(); await setupGitAsOSBotify(); if (!(await refExists('staging'))) { - await exec`git fetch origin staging --depth=1`; + await $`git fetch origin staging --depth=1`; } - await exec`git switch staging`; - await exec`git tag ${getVersion()}-staging`; - await exec`git push --tags`; + await $`git switch staging`; + await $`git tag ${getVersion()}-staging`; + await $`git push --tags`; Log.success(`Created new tag ${getVersion()}`); } @@ -428,11 +427,11 @@ async function tagProduction() { await checkoutRepo(); await setupGitAsOSBotify(); if (!(await refExists('production'))) { - await exec`git fetch origin production --depth=1`; + await $`git fetch origin production --depth=1`; } - await exec`git switch production`; - await exec`git tag ${getVersion()}`; - await exec`git push --tags`; + await $`git switch production`; + await $`git tag ${getVersion()}`; + await $`git push --tags`; Log.success(`Created new tag ${getVersion()}`); } @@ -570,12 +569,12 @@ describe.serial('CIGitLogic', () => { test('Deploying a PR, then CPing a revert, then adding the same code back again before the next production deploy results in the correct code on staging and production', async () => { Log.info('Creating myFile.txt in PR #7'); await setupGitAsHuman(); - await exec`git switch main`; - await exec`git switch -c pr-7`; + await $`git switch main`; + await $`git switch -c pr-7`; const initialFileContent = 'Changes from PR #7'; fs.appendFileSync('myFile.txt', 'Changes from PR #7'); - await exec`git add myFile.txt`; - await exec`git commit -m "Add myFile.txt in PR #7"`; + await $`git add myFile.txt`; + await $`git commit -m "Add myFile.txt in PR #7"`; await mergePR(7); await deployStaging(); @@ -588,16 +587,16 @@ describe.serial('CIGitLogic', () => { Log.info('Appending and prepending content to myFile.txt in PR #8'); await setupGitAsHuman(); - await exec`git switch main`; - await exec`git switch -c pr-8`; + await $`git switch main`; + await $`git switch -c pr-8`; const newFileContent = ` Prepended content ${initialFileContent} Appended content `; fs.writeFileSync('myFile.txt', newFileContent, {encoding: 'utf-8'}); - await exec`git add myFile.txt`; - await exec`git commit -m "Append and prepend content in myFile.txt"`; + await $`git add myFile.txt`; + await $`git commit -m "Append and prepend content in myFile.txt"`; await mergePR(8); await deployStaging(); @@ -609,20 +608,20 @@ Appended content Log.info('Making an unrelated change in PR #9'); await setupGitAsHuman(); - await exec`git switch main`; - await exec`git switch -c pr-9`; + await $`git switch main`; + await $`git switch -c pr-9`; fs.appendFileSync('anotherFile.txt', 'some content'); - await exec`git add anotherFile.txt`; - await exec`git commit -m "Create another file"`; + await $`git add anotherFile.txt`; + await $`git commit -m "Create another file"`; await mergePR(9); Log.info('Reverting the append + prepend on main in PR #10'); await setupGitAsHuman(); - await exec`git switch main`; - await exec`git switch -c pr-10`; + await $`git switch main`; + await $`git switch -c pr-10`; fs.writeFileSync('myFile.txt', initialFileContent); - await exec`git add myFile.txt`; - await exec`git commit -m "Revert append and prepend"`; + await $`git add myFile.txt`; + await $`git commit -m "Revert append and prepend"`; await mergePR(10); await cherryPickPRToStaging(10); @@ -632,11 +631,11 @@ Appended content Log.info('Repeating previously reverted append + prepend on main in PR #10'); await setupGitAsHuman(); - await exec`git switch main`; - await exec`git switch -c pr-11`; + await $`git switch main`; + await $`git switch -c pr-11`; fs.writeFileSync('myFile.txt', newFileContent, {encoding: 'utf-8'}); - await exec`git add myFile.txt`; - await exec`git commit -m "Append and prepend content in myFile.txt"`; + await $`git add myFile.txt`; + await $`git commit -m "Append and prepend content in myFile.txt"`; await mergePR(11); await deployProduction(); @@ -650,7 +649,7 @@ Appended content test('Force-pushing to a branch after rebasing older commits', async () => { await createBasicPR(12); - await exec`git push origin pr-12`; + await $`git push origin pr-12`; await createBasicPR(13); await mergePR(13); await deployStaging(); @@ -663,10 +662,10 @@ Appended content await checkoutRepo(); await setupGitAsHuman(); - await exec`git fetch origin pr-12`; - await exec`git switch pr-12`; - await exec`git rebase main -Xours`; - await exec`git push --force origin pr-12`; + await $`git fetch origin pr-12`; + await $`git switch pr-12`; + await $`git rebase main -Xours`; + await $`git push --force origin pr-12`; await mergePR(12); await deployProduction(); @@ -682,13 +681,13 @@ Appended content Log.info('Creating manual version bump in PR #14'); await checkoutRepo(); await setupGitAsHuman(); - await exec`git pull`; - await exec`git switch -c pr-14`; + await $`git pull`; + await $`git switch -c pr-14`; for (let i = 0; i < 3; i++) { - await exec`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`; + await $`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`; } - await exec`git add package.json`; - await exec`git commit -m "Manually bump version to ${getVersion()} in PR #14"`; + await $`git add package.json`; + await $`git commit -m "Manually bump version to ${getVersion()} in PR #14"`; Log.success('Created manual version bump in PR #13 in branch pr-14'); await mergePR(14); @@ -704,13 +703,13 @@ Appended content Log.info('Creating manual version bump in PR #15'); await checkoutRepo(); await setupGitAsHuman(); - await exec`git pull`; - await exec`git switch -c pr-15`; + await $`git pull`; + await $`git switch -c pr-15`; for (let i = 0; i < 3; i++) { - await exec`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`; + await $`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`; } - await exec`git add package.json`; - await exec`git commit -m "Manually bump version to ${getVersion()} in PR #15"`; + await $`git add package.json`; + await $`git commit -m "Manually bump version to ${getVersion()} in PR #15"`; Log.success('Created manual version bump in PR #15 in branch pr-15'); const packageJSONBefore = fs.readFileSync('package.json', {encoding: 'utf-8'}); @@ -719,11 +718,11 @@ Appended content 15, async () => { fs.writeFileSync('package.json', packageJSONBefore); - await exec`git add package.json`; - await exec`git cherry-pick --no-edit --continue`; + await $`git add package.json`; + await $`git cherry-pick --no-edit --continue`; }, async () => { - await exec`git commit --no-edit --allow-empty`; + await $`git commit --no-edit --allow-empty`; }, ); From e3740d8cf737ab0f449fe25b7c449492b6044fa7 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 15:49:36 -0700 Subject: [PATCH 19/24] Drop the TEST_VERBOSE escape hatch It was this branch's rename of main's JEST_VERBOSE knob, kept through the migration without anything wiring it up - there is no test:bun:verbose script, so reaching it meant setting the env var by hand. Removed from all three places it had spread to: the shell wrapper's .quiet(), setup.ts's console stubbing, which is now unconditional, and the command list in the README. Failures are unaffected either way, since bun:test reports those itself rather than through console. --- tests/tooling/CIGitLogic.test.ts | 18 ++---------------- tests/tooling/README.md | 3 --- tests/tooling/setup.ts | 22 ++++++++++------------ 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index 9c67c3cdebf3..cbf2328ebb8b 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -29,25 +29,11 @@ const DUMMY_DIR = path.join(SANDBOX_DIR, 'checkout'); const mockGetInput = jest.fn<(name: string) => string | undefined>(); type CompareCommitsCommit = NonNullable>['data']['commits']>[number]; -const isVerbose = process.env.TEST_VERBOSE === 'true'; - -/** - * Bun's shell, wrapped to log each command and to keep the subprocess output quiet unless TEST_VERBOSE is set. - * - * Interpolated values are escaped by Bun, so call sites need no quoting: `` $`git commit -m ${message}` `` is - * correct even when the message contains spaces. Commands run in `process.cwd()`, which the suite moves between - * the remote and the checkout. - * - * A Proxy rather than a wrapper function because `$` returns a lazy, chainable `ShellPromise`, and `.text()` and - * `.nothrow()` both have to survive the wrapping — a function that awaited internally would destroy them. - * For the same reason nothing here handles failures: attaching a rejection handler would start the command - * early, and later `.quiet()`/`.cwd()` calls would then throw "Shell is already running". It is not needed - * anyway, as bun:test prints the ShellError with its stderr and the source line of the failing command. - */ +// Bun's shell, wrapped to log each command and to keep the subprocess output quiet. const $ = new Proxy(bun$, { apply(target, thisArg, args: [TemplateStringsArray, ...string[]]) { Log.info(String.raw({raw: args[0]}, ...args.slice(1))); - return Reflect.apply(target, thisArg, args).quiet(!isVerbose); + return Reflect.apply(target, thisArg, args).quiet(); }, }); diff --git a/tests/tooling/README.md b/tests/tooling/README.md index 0cd544cdaaa4..9a6e63c9164e 100644 --- a/tests/tooling/README.md +++ b/tests/tooling/README.md @@ -20,9 +20,6 @@ TZ=utc bun test --parallel --preload ./scripts/stubReactNative.js --preload ./te # One test, by name npm run test:bun -- -t 'getPullRequestNumberFromURL' - -# Show the output the code under test writes (silenced by default, see setup.ts) -TEST_VERBOSE=true npm run test:bun ``` The leading `./` on a file path is required: without it Bun treats the argument as a name filter and finds diff --git a/tests/tooling/setup.ts b/tests/tooling/setup.ts index aff7250d3bde..f4ca502ad5d1 100644 --- a/tests/tooling/setup.ts +++ b/tests/tooling/setup.ts @@ -11,15 +11,13 @@ if (!('GITHUB_REPOSITORY' in process.env)) { } // The code under test logs heavily, which drowns out the actual results. Jest's CI runs pass --silent for the same -// reason; `bun test` has no equivalent flag, so swap in stubs. Set TEST_VERBOSE=true to see the output. Workflow -// commands that @actions/core writes straight to process.stdout still come through. -if (process.env.TEST_VERBOSE !== 'true') { - globalThis.console = { - ...console, - log: jest.fn(), - info: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; -} +// reason; `bun test` has no equivalent flag, so swap in stubs. Workflow commands that @actions/core writes straight +// to process.stdout still come through, as does anything bun:test reports about a failure. +globalThis.console = { + ...console, + log: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; From fc8405baa5914248611c5d29eadcba83dae62b9a Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 12 Aug 2026 16:07:33 -0700 Subject: [PATCH 20/24] Keep commit message prose out of the shell templates knip parses these template literals to find binaries the repo invokes. In git merge ${branchName} --no-ff -m "Merge pull request #${num} from ..." the # opens a comment as far as its shell parser is concerned, so the next bare word reads as a command name and it reported a missing `from` binary - the one new finding on this PR (main 134, PR 131, added 1, resolved 4). Interpolating the message instead of inlining it fixes that, because the prose is then a value Bun escapes rather than shell source anything can parse. Applied to the three git merge calls that failed and the three commit messages carrying the same latent shape, so the rule is uniform: human-readable messages are interpolated, never inline. Messages without a # are left quoted, which knip reads fine. The merge messages are load-bearing - GitUtils parses PR numbers back out of them - so the escaped form has to be byte-identical. assertPRsMergedBetween covers exactly that, and the suite passes 9/9. --- tests/tooling/CIGitLogic.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/tooling/CIGitLogic.test.ts b/tests/tooling/CIGitLogic.test.ts index cbf2328ebb8b..ad7e93c08d0a 100644 --- a/tests/tooling/CIGitLogic.test.ts +++ b/tests/tooling/CIGitLogic.test.ts @@ -297,7 +297,8 @@ async function mergePR(num: number) { Log.info(`Merging PR #${num} to main`); await $`git switch main`; - await $`git merge ${branchName} --no-ff -m "Merge pull request #${num} from Expensify/${branchName}"`; + const mergeMessage = `Merge pull request #${num} from Expensify/${branchName}`; + await $`git merge ${branchName} --no-ff -m ${mergeMessage}`; await $`git push origin main`; await $`git branch -d ${branchName}`; Log.success(`Merged PR #${num} to main`); @@ -336,7 +337,8 @@ async function cherryPickPRToStaging(num: number, resolveVersionBumpConflicts: ( await setupGitAsOSBotify(); await $`git switch staging`; - await $`git merge cherry-pick-staging --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-staging"`; + const mergeMessage = `Merge pull request #${num + 1} from Expensify/cherry-pick-staging`; + await $`git merge cherry-pick-staging --no-ff -m ${mergeMessage}`; await $`git branch -d cherry-pick-staging`; await $`git push origin staging`; Log.info(`Merged PR #${num + 1} into staging`); @@ -375,7 +377,8 @@ async function cherryPickPRToProduction(num: number, resolveVersionBumpConflicts await setupGitAsOSBotify(); await $`git switch production`; - await $`git merge cherry-pick-production --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-production"`; + const mergeMessage = `Merge pull request #${num + 1} from Expensify/cherry-pick-production`; + await $`git merge cherry-pick-production --no-ff -m ${mergeMessage}`; await $`git branch -d cherry-pick-production`; await $`git push origin production`; Log.info(`Merged PR #${num + 1} into production`); @@ -560,7 +563,8 @@ describe.serial('CIGitLogic', () => { const initialFileContent = 'Changes from PR #7'; fs.appendFileSync('myFile.txt', 'Changes from PR #7'); await $`git add myFile.txt`; - await $`git commit -m "Add myFile.txt in PR #7"`; + const commitMessage = 'Add myFile.txt in PR #7'; + await $`git commit -m ${commitMessage}`; await mergePR(7); await deployStaging(); @@ -673,7 +677,8 @@ Appended content await $`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`; } await $`git add package.json`; - await $`git commit -m "Manually bump version to ${getVersion()} in PR #14"`; + const bumpMessage14 = `Manually bump version to ${getVersion()} in PR #14`; + await $`git commit -m ${bumpMessage14}`; Log.success('Created manual version bump in PR #13 in branch pr-14'); await mergePR(14); @@ -695,7 +700,8 @@ Appended content await $`npm --no-git-tag-version version ${VersionUpdater.incrementVersion(getVersion(), VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR)}`; } await $`git add package.json`; - await $`git commit -m "Manually bump version to ${getVersion()} in PR #15"`; + const bumpMessage15 = `Manually bump version to ${getVersion()} in PR #15`; + await $`git commit -m ${bumpMessage15}`; Log.success('Created manual version bump in PR #15 in branch pr-15'); const packageJSONBefore = fs.readFileSync('package.json', {encoding: 'utf-8'}); From 32f931d761f7187d719cd623e546d78bb59c563d Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 13 Aug 2026 12:50:57 -0700 Subject: [PATCH 21/24] Type the deploy checklist assertions instead of casting to unknown Review feedback: `expect(result as unknown).toStrictEqual({html_url})` appeared 12 times. The cast was there because run() resolves to IssuesCreateResponse, which a bare {html_url} literal is not assignable to, but it also switched off checking on both sides - a typo in the field name would have compared clean. createMock({html_url}) is the convention this file already uses elsewhere: the partial is checked against the real response type, and the single assertion stays isolated in createMock. The three remaining casts in the file are gone too. Those stubs had to satisfy an octokit method type, which carries defaults/endpoint statics a plain function cannot have. Copying the real statics onto each stub makes them genuinely satisfy the type, so the assertion and its eslint-disable are no longer needed. The file now has no 'as unknown'. --- .../createOrUpdateDeployChecklist.test.ts | 118 +++++++++++------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/tests/tooling/createOrUpdateDeployChecklist.test.ts b/tests/tooling/createOrUpdateDeployChecklist.test.ts index 984856b72499..cd9793dd7896 100644 --- a/tests/tooling/createOrUpdateDeployChecklist.test.ts +++ b/tests/tooling/createOrUpdateDeployChecklist.test.ts @@ -24,6 +24,8 @@ await mock.module('fs', () => ({...memfsFs, default: memfsFs})); // Must be imported after the mock.module() call above so it picks up the mock. const {default: run} = await import('@scripts/createOrUpdateDeployChecklist'); +type IssuesCreateResponse = Awaited>['data']; + const mockGetInput = jest.fn(); type ListForRepoParameters = Parameters; @@ -40,6 +42,7 @@ const PATH_TO_PACKAGE_JSON = path.resolve(__dirname, '../../package.json'); let mockCreateIssue: Mock; let mockUpdateIssue: Mock; let mockListIssues: Mock; +let listForRepoStatics: Pick; const mockGetMergedPRsDeployedBetween = jest.fn(); const mockGetWorkflowRunURLForCommit = jest.fn().mockResolvedValue(undefined); @@ -55,8 +58,7 @@ beforeAll(() => { } // Octokit endpoint methods carry `defaults`/`endpoint` statics. A Bun mock doesn't, and `paginate` reads them - // off the method, so they're copied back onto each mock below. mockImplementation insists on the statics too, - // which a plain stub can't provide - only the call signature matters here. + // off the method, so the real ones are copied back onto each mock and stub below. const createIssue = (...args: CreateIssueParameters): Promise => { const [arg] = args; if (!arg) { @@ -83,15 +85,12 @@ beforeAll(() => { }), ); }; - mockCreateIssue = jest - .spyOn(mockOctokit.rest.issues, 'create') - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the stub deliberately omits the statics described above - .mockImplementation(createIssue as unknown as InternalOctokit['rest']['issues']['create']); - mockUpdateIssue = jest - .spyOn(mockOctokit.rest.issues, 'update') - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the stub deliberately omits the statics described above - .mockImplementation(updateIssue as unknown as InternalOctokit['rest']['issues']['update']); + const {endpoint: createEndpoint, defaults: createDefaults} = mockOctokit.rest.issues.create; + const {endpoint: updateEndpoint, defaults: updateDefaults} = mockOctokit.rest.issues.update; + mockCreateIssue = jest.spyOn(mockOctokit.rest.issues, 'create').mockImplementation(Object.assign(createIssue, {endpoint: createEndpoint, defaults: createDefaults})); + mockUpdateIssue = jest.spyOn(mockOctokit.rest.issues, 'update').mockImplementation(Object.assign(updateIssue, {endpoint: updateEndpoint, defaults: updateDefaults})); const {endpoint: listForRepoEndpoint, defaults: listForRepoDefaults} = mockOctokit.rest.issues.listForRepo; + listForRepoStatics = {endpoint: listForRepoEndpoint, defaults: listForRepoDefaults}; const {endpoint: pullsListEndpoint, defaults: pullsListDefaults} = mockOctokit.rest.pulls.list; mockListIssues = Object.assign(jest.spyOn(mockOctokit.rest.issues, 'listForRepo'), {endpoint: listForRepoEndpoint, defaults: listForRepoDefaults}); const mockListPullRequests = Object.assign(jest.spyOn(mockOctokit.rest.pulls, 'list'), {endpoint: pullsListEndpoint, defaults: pullsListDefaults}); @@ -151,8 +150,7 @@ function mockDeployChecklistIssuesByLabel(responseByLabel: Partial({data, headers: {}})); }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the stub deliberately omits the statics described in beforeAll - mockListIssues.mockImplementation(listForRepo as unknown as ListForRepoMethod); + mockListIssues.mockImplementation(Object.assign(listForRepo, listForRepoStatics)); } const LABELS = { @@ -342,9 +340,11 @@ describe('createOrUpdateDeployChecklist', () => { `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreak}${ccApplauseLeads}`, }); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); }); test('creates new issue when there are no Mobile-Expensify PRs', async () => { @@ -388,9 +388,11 @@ describe('createOrUpdateDeployChecklist', () => { `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreak}${ccApplauseLeads}`, }); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); }); describe('updates existing issue when there is one open', () => { @@ -507,9 +509,11 @@ describe('createOrUpdateDeployChecklist', () => { `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreak}${ccApplauseLeads}`, }); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/${openDeployChecklistBefore.number}`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/${openDeployChecklistBefore.number}`, + }), + ); }); test('without NPM_VERSION input, just a new deploy blocker', async () => { @@ -575,9 +579,11 @@ describe('createOrUpdateDeployChecklist', () => { `${lineBreak}${closedCheckbox}${ghVerification}` + `${lineBreak}${ccApplauseLeads}`, }); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/${openDeployChecklistBefore.number}`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/${openDeployChecklistBefore.number}`, + }), + ); }); test('without Mobile-Expensify PRs, just app PRs and deploy blockers', async () => { @@ -624,9 +630,11 @@ describe('createOrUpdateDeployChecklist', () => { `${lineBreak}${closedCheckbox}${ghVerification}` + `${lineBreak}${ccApplauseLeads}`, }); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/${openDeployChecklistBefore.number}`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/${openDeployChecklistBefore.number}`, + }), + ); }); }); @@ -685,9 +693,11 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); const createCall = mockCreateIssue.mock.lastCall; if (!createCall || !createCall[0]) { throw new Error('Expected issues.create to receive a request payload.'); @@ -753,9 +763,11 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); const createCall = mockCreateIssue.mock.lastCall; if (!createCall || !createCall[0]) { throw new Error('Expected issues.create to receive a request payload.'); @@ -824,9 +836,11 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); const createCall = mockCreateIssue.mock.lastCall; if (!createCall || !createCall[0]) { throw new Error('Expected issues.create to receive a request payload.'); @@ -874,9 +888,11 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); const createCall = mockCreateIssue.mock.lastCall; if (!createCall || !createCall[0]) { throw new Error('Expected issues.create to receive a request payload.'); @@ -950,9 +966,11 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); const updateCall = mockUpdateIssue.mock.lastCall; if (!updateCall || !updateCall[0]) { throw new Error('Expected issues.update to receive a request payload.'); @@ -1028,9 +1046,11 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); const createCall = mockCreateIssue.mock.lastCall; if (!createCall || !createCall[0]) { throw new Error('Expected issues.create to receive a request payload.'); @@ -1090,9 +1110,11 @@ describe('createOrUpdateDeployChecklist', () => { }); const result = await run(); - expect(result as unknown).toStrictEqual({ - html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, - }); + expect(result).toStrictEqual( + createMock({ + html_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/issues/29`, + }), + ); const createCall = mockCreateIssue.mock.lastCall; if (!createCall || !createCall[0]) { throw new Error('Expected issues.create to receive a request payload.'); From f4184d1cbc5ef5754926dc88cb933b8600ddaaa9 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 13 Aug 2026 15:32:56 -0700 Subject: [PATCH 22/24] Run ESLint on the 64GB runner A cold-cache lint loads the full TypeScript program into every worker (~12GB each, per the note above runs-on), so 2 workers already needed ~30GB of the 32GB runner. This branch maps CIGitLogic.test.ts to a second tsconfig project - it needs @types/bun, whose globals cannot go in the root project - and each worker then holds that program as well. That overshot the remaining headroom and the runner was killed mid-lint, twice, with no ESLint output at all: the annotation reports lost communication from memory starvation rather than any lint error. Only this branch hit it because the ESLint cache key hashes config/eslint/**, which this branch changes, so it always takes the cold path. Other branches restore a warm cache and finish in 1.5-4 minutes; these runs ground on for 13 and 17 minutes before dying. blacksmith-16vcpu-ubuntu-2404 is already used by two other workflows here. Concurrency stays at 2 workers, so this only adds headroom rather than changing how lint runs. --- .github/workflows/lint.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0627965accd5..ae48cba897d0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,8 +18,11 @@ jobs: if: ${{ github.event.head_commit.author.name != 'OSBotify' || github.event_name == 'push' }} # A cold-cache run lints the whole repo with type-aware rules, which loads the full TypeScript # program in every worker (~12GB of heap each, regardless of how files are split between workers). - # 2 workers with a 14GB heap cap need ~30GB of memory, so this requires the 32GB (8vcpu) runner. - runs-on: blacksmith-8vcpu-ubuntu-2404 + # 2 workers with a 14GB heap cap need ~30GB, which left almost no headroom on the 32GB (8vcpu) + # runner: a file mapped to a second tsconfig project (tests/tooling/CIGitLogic.test.ts) makes each + # worker hold that program too, and the runner was killed mid-lint for exhausting memory. The 64GB + # (16vcpu) runner has room for that and for the next project added. + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 From 040be27ef4c477cce50b7ae82ea32dea5d13a851 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 13 Aug 2026 15:34:48 -0700 Subject: [PATCH 23/24] Trigger the lint workflow on changes to itself The paths filter did not list lint.yml, so the previous commit changing the runner did not start a lint run and the PR kept showing the older failure. Editing how lint runs should re-run lint. Also condenses the runs-on comment. The array formatting is oxfmt's. --- .github/workflows/lint.yml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ae48cba897d0..3237ac966baa 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,7 +6,21 @@ on: types: [opened, synchronize] branches-ignore: [staging, production] paths: - ['**.js', '**.ts', '**.tsx', '**.json', '**.mjs', '**.cjs', 'config/.editorconfig', 'config/eslint/**', 'scripts/lint.sh', 'scripts/lintChanged.sh', '.watchmanconfig', '.imgbotconfig'] + [ + '**.js', + '**.ts', + '**.tsx', + '**.json', + '**.mjs', + '**.cjs', + 'config/.editorconfig', + 'config/eslint/**', + 'scripts/lint.sh', + 'scripts/lintChanged.sh', + '.watchmanconfig', + '.imgbotconfig', + '.github/workflows/lint.yml', + ] concurrency: group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-lint @@ -16,12 +30,8 @@ jobs: lint: name: ESLint check if: ${{ github.event.head_commit.author.name != 'OSBotify' || github.event_name == 'push' }} - # A cold-cache run lints the whole repo with type-aware rules, which loads the full TypeScript - # program in every worker (~12GB of heap each, regardless of how files are split between workers). - # 2 workers with a 14GB heap cap need ~30GB, which left almost no headroom on the 32GB (8vcpu) - # runner: a file mapped to a second tsconfig project (tests/tooling/CIGitLogic.test.ts) makes each - # worker hold that program too, and the runner was killed mid-lint for exhausting memory. The 64GB - # (16vcpu) runner has room for that and for the next project added. + # A cold-cache run loads the full TypeScript program into every worker (~12GB each), plus any extra + # tsconfig project a file is mapped to. 2 workers need more than the 32GB an 8vcpu runner has. runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout From d9750f6bcc665b56650fa4b8f5e5b8664f0e91f5 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 13 Aug 2026 16:38:47 -0700 Subject: [PATCH 24/24] Fix the unsafe octokit assertions the rename un-grandfathered The seatbelt baseline grandfathered these under tests/unit/*Test.ts, which the migration renamed, so the violations came back as errors under the new paths. Local lint hid it: seatbelt is readOnly unless CI is set, so this only reproduces with CI=true. awaitStagingDeploys: all three are gone. It builds a stub octokit purely to swap listWorkflowRuns, so it now initialises a real client and spies on that method, copying the real defaults/endpoint statics onto the stub - the same fix already used in waitForPreviousRuns. The InternalOctokit import went with it. markPullRequestsAsDeployed keeps its assertion behind an inline disable. The action memoizes GithubUtils.octokit.git.getCommit at module load, so the mock has to exist before the import and a real client cannot be spied on in time; the stub also replaces paginate, which a real client would run for real against stubbed responses. createMock does not help either, as PartialDeep still demands octokit's method types. Net effect against main: 5 grandfathered violations across these two files become 1 documented exception, and the baseline is untouched. --- tests/tooling/awaitStagingDeploys.test.ts | 17 +++++------------ .../tooling/markPullRequestsAsDeployed.test.ts | 1 + 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/tooling/awaitStagingDeploys.test.ts b/tests/tooling/awaitStagingDeploys.test.ts index 38b567537c12..0f6e2b3c0aee 100644 --- a/tests/tooling/awaitStagingDeploys.test.ts +++ b/tests/tooling/awaitStagingDeploys.test.ts @@ -3,7 +3,6 @@ import {beforeAll, beforeEach, describe, expect, jest, test} from 'bun:test'; import run from '@github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys'; import CONST from '@github/libs/CONST'; -import type {InternalOctokit} from '@github/libs/GithubUtils'; import GithubUtils from '@github/libs/GithubUtils'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -67,17 +66,11 @@ beforeAll(() => { // reassigned directly (unlike Jest's Babel-transpiled CJS interop); spy on it instead. jest.spyOn(core, 'getInput').mockImplementation(mockGetInput); - // Mock octokit module - const mockOctokit = { - rest: { - actions: { - ...(GithubUtils.internalOctokit as unknown as typeof GithubUtils.octokit.actions), - listWorkflowRuns: mockListWorkflowRuns as unknown as typeof GithubUtils.octokit.actions.listWorkflowRuns, - }, - }, - }; - - GithubUtils.internalOctokit = mockOctokit as InternalOctokit; + // Octokit endpoint methods carry `defaults`/`endpoint` statics that a bare mock doesn't, so the real ones are + // copied onto the stub rather than asserted away. + GithubUtils.initOctokitWithToken('fake_token'); + const {endpoint, defaults} = GithubUtils.octokit.actions.listWorkflowRuns; + jest.spyOn(GithubUtils.octokit.actions, 'listWorkflowRuns').mockImplementation(Object.assign(mockListWorkflowRuns, {endpoint, defaults})); }); beforeEach(() => { diff --git a/tests/tooling/markPullRequestsAsDeployed.test.ts b/tests/tooling/markPullRequestsAsDeployed.test.ts index 15e94db15c66..21eac000e4a0 100644 --- a/tests/tooling/markPullRequestsAsDeployed.test.ts +++ b/tests/tooling/markPullRequestsAsDeployed.test.ts @@ -85,6 +85,7 @@ const mockOctokit = { }, paginate: jest.fn().mockImplementation((objectMethod: () => Promise>) => objectMethod().then(({data}) => data)), }; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the stub implements only the endpoints this action touches, and the load-order constraint above rules out initialising a real octokit and spying on it GithubUtils.internalOctokit = mockOctokit as unknown as InternalOctokit; // Must be imported after the GithubUtils.internalOctokit setup above so it picks up the mocks.