From e320f7fbc428c2ea52fbfadd0adf4e96d959e895 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 6 Sep 2026 01:17:59 +0000 Subject: [PATCH 1/2] test(app-shell,console): inherit the real @object-ui/i18n surface in 29 mock factories (objectui#7337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `vi.mock('@object-ui/i18n', () => ({ … }))` factory that hand-lists its exports freezes the mock to the names that existed the day it was written. The next module-scope read of a new export kills the importing file during COLLECTION, so the suite reports zero failed assertions and the red lands on an innocent test file. - convert 29 frozen factories to the obtain-and-spread form; - delete `apps/console/dev/__tests__/setup/common-mocks.ts`, a mock helper with zero importers repo-wide carrying the same shape; - fix the recogniser's nested-generic blind spot: `<[^>]*>` stopped at the first `>`, so `vi.importActual>(…)` went unmatched and four factories that obtain AND spread the real module were reported as frozen. Repo-wide across all 21 workspace specifiers: 349 frozen -> 344, with no site moving the other way; - pin the failure mode itself: three nested vitest runs over a throwaway package show the frozen factory dying at collection, the converted one passing, and a lazy read failing as an ordinary assertion. `COVERED_SPECIFIERS` is deliberately NOT widened: one frozen factory remains in `DeclaredActionsBar.test.tsx`, held by open PR #7846, and flipping the ratchet while it exists turns `main` red on merge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KDq78vMMSzCGWGmhUYBabh --- .changeset/7337-vi-mock-i18n-inherit.md | 8 + .../dev/__tests__/setup/common-mocks.ts | 95 ------ apps/console/src/pages/docs-portal.test.tsx | 3 +- .../ApprovalsInboxPage.cellIdentity.test.tsx | 5 +- ...alsInboxPage.characterizationPins.test.tsx | 5 +- ...pprovalsInboxPage.hiddenFieldTrim.test.tsx | 5 +- ...rovalsInboxPage.queueHiddenAmount.test.tsx | 5 +- ...ApprovalsInboxPage.rawPayloadGate.test.tsx | 5 +- .../ApprovalsInboxPage.recordLink.test.tsx | 5 +- ...alsInboxPage.stepProgressVertical.test.tsx | 5 +- .../src/console/ai/LiveCanvas.test.tsx | 3 +- .../__tests__/CreateWorkspaceDialog.test.tsx | 3 +- .../InviteMemberDialog.placement.test.tsx | 3 +- .../InviteMemberDialog.roleCap.test.tsx | 3 +- .../__tests__/OrganizationsPage.test.tsx | 3 +- .../acceptInvitationLink.mount.test.tsx | 3 +- .../useConsoleActionRuntime.test.tsx | 3 +- .../hooks/__tests__/useObjectActions.test.tsx | 3 +- ...ityFeed.unknownKindFailsOpen-6816.test.tsx | 3 +- .../__tests__/AiUsageIndicator.test.tsx | 3 +- .../src/layout/__tests__/ChatDock.test.tsx | 3 +- .../__tests__/ConsoleChatbotFab.test.tsx | 3 +- .../CurrentOrganizationIndicator.test.tsx | 3 +- .../__tests__/WorkspaceSwitcher.test.tsx | 3 +- .../__tests__/DraftChangesPanel.test.tsx | 3 +- .../__tests__/DraftPreviewBar.test.tsx | 3 +- .../views/DashboardView.modalTarget.test.tsx | 3 +- .../DashboardView.rootTitleRetired.test.tsx | 3 +- .../ReportView.dataSourceObjectKey.test.tsx | 3 +- ...aredActionsBar.overrideAffordance.test.tsx | 3 +- .../src/views/__tests__/PageView.test.tsx | 3 +- .../__tests__/check-vi-mock-inherit.test.ts | 287 +++++++++++++++++- scripts/check-vi-mock-inherit.mjs | 74 ++++- scripts/check-vi-mock-specifiers.mjs | 24 +- 34 files changed, 440 insertions(+), 149 deletions(-) create mode 100644 .changeset/7337-vi-mock-i18n-inherit.md delete mode 100644 apps/console/dev/__tests__/setup/common-mocks.ts diff --git a/.changeset/7337-vi-mock-i18n-inherit.md b/.changeset/7337-vi-mock-i18n-inherit.md new file mode 100644 index 0000000000..844a186fb0 --- /dev/null +++ b/.changeset/7337-vi-mock-i18n-inherit.md @@ -0,0 +1,8 @@ +--- +--- + +Sweep the `vi.mock('@object-ui/i18n', …)` factories that hand-list the mock's +export surface over to the obtain-and-spread form, and fix the +`check-vi-mock-inherit` recogniser's nested-generic blind spot +(objectui#7337). Test and CI-script only; no package is released by this +change. diff --git a/apps/console/dev/__tests__/setup/common-mocks.ts b/apps/console/dev/__tests__/setup/common-mocks.ts deleted file mode 100644 index 9c2da676fb..0000000000 --- a/apps/console/dev/__tests__/setup/common-mocks.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Common mocks for Console tests. - * - * A LOT of Console test files duplicate the same i18n / auth / permissions / - * lucide-react mocks. Collect them here so new test files can do: - * - * import { applyCommonConsoleMocks } from './setup/common-mocks'; - * applyCommonConsoleMocks(); - * - * ...and inherit a sensible baseline. Individual tests can still override any - * specific mock by calling `vi.mock(..., ...)` after this import. - * - * IMPORTANT: `vi.mock` is hoisted by Vitest to the TOP of the importing file, - * so calling a helper that itself calls `vi.mock` only works when the helper - * is imported BEFORE any code that resolves the mocked module. Keep this - * import at the very top of your test file. - */ - -import { vi } from 'vitest'; -import React from 'react'; - -/** - * Apply the default Console test mocks. - * - * @param overrides - Optional partial override for any of the individual - * mock factories. Pass `null` for a key to opt out of the - * default mock for that module. - */ -export function applyCommonConsoleMocks(overrides: { - i18n?: boolean; - auth?: boolean; - permissions?: boolean; - lucide?: boolean; -} = {}) { - const { i18n = true, auth = true, permissions = true, lucide = true } = overrides; - - if (i18n) { - vi.mock('@object-ui/i18n', () => ({ - useObjectTranslation: () => ({ - t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key, - language: 'en', - changeLanguage: vi.fn(), - direction: 'ltr' as const, - i18n: {}, - }), - useObjectLabel: () => ({ - objectLabel: (obj: any) => obj?.label ?? obj?.name, - objectDescription: (obj: any) => obj?.description, - fieldLabel: (_o: string, _f: string, fallback: string) => fallback, - appLabel: (app: any) => app?.label ?? app?.name, - appDescription: (app: any) => app?.description, - }), - useSafeFieldLabel: () => ({ - fieldLabel: (_o: string, _f: string, fallback: string) => fallback, - }), - })); - } - - if (auth) { - vi.mock('@object-ui/auth', () => ({ - useAuth: () => ({ - user: { name: 'Test User', email: 'test@test.com' }, - signOut: vi.fn(), - organizations: [], - activeOrganization: null, - isOrganizationsLoading: false, - switchOrganization: vi.fn(), - }), - getUserInitials: () => 'TU', - AuthGuard: ({ children }: any) => React.createElement(React.Fragment, null, children), - PreviewBanner: () => null, - })); - } - - if (permissions) { - vi.mock('@object-ui/permissions', () => ({ - usePermissions: () => ({ can: () => true, cannot: () => false }), - })); - } - - if (lucide) { - // Minimal lucide-react mock using a Proxy so any imported icon resolves to - // a no-op span without the test file having to enumerate icons. - vi.mock('lucide-react', () => { - const MockIcon = ({ className, ...rest }: any) => - React.createElement('span', { 'data-testid': 'lucide-icon', className, ...rest }); - return new Proxy( - { default: MockIcon }, - { - get: (target: any, prop: string) => (prop in target ? target[prop] : MockIcon), - }, - ); - }); - } -} diff --git a/apps/console/src/pages/docs-portal.test.tsx b/apps/console/src/pages/docs-portal.test.tsx index 7cf9133451..79f0836692 100644 --- a/apps/console/src/pages/docs-portal.test.tsx +++ b/apps/console/src/pages/docs-portal.test.tsx @@ -56,7 +56,8 @@ vi.mock('@object-ui/plugin-markdown', () => ({ extractToc: () => [], })); -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k }), })); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.cellIdentity.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.cellIdentity.test.tsx index ffeb248e90..cde48c5f7a 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.cellIdentity.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.cellIdentity.test.tsx @@ -105,7 +105,10 @@ const { approvalsApiStub, ADAPTER, AUTH, I18N } = vi.hoisted(() => { return { approvalsApiStub, ADAPTER, AUTH, I18N }; }); -vi.mock('@object-ui/i18n', () => ({ useObjectTranslation: () => I18N })); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); vi.mock('@object-ui/auth', () => { const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.characterizationPins.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.characterizationPins.test.tsx index 813eae2b3d..ec2b9d5974 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.characterizationPins.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.characterizationPins.test.tsx @@ -132,7 +132,10 @@ const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, OWNER_DISPLAY, A return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, OWNER_DISPLAY, AMOUNT_DISPLAY }; }); -vi.mock('@object-ui/i18n', () => ({ useObjectTranslation: () => I18N })); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); vi.mock('@object-ui/auth', () => { const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx index 84c5cd2a38..a4b67ac663 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx @@ -128,7 +128,10 @@ const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, ROW } = vi.hoist return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, ROW }; }); -vi.mock('@object-ui/i18n', () => ({ useObjectTranslation: () => I18N })); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); vi.mock('@object-ui/auth', () => { const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.queueHiddenAmount.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.queueHiddenAmount.test.tsx index 97011ed744..a852c71da7 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.queueHiddenAmount.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.queueHiddenAmount.test.tsx @@ -184,7 +184,10 @@ const { approvalsApiStub, getObjectSchema, SCHEMAS, ADAPTER, AUTH, I18N, ROWS } return { approvalsApiStub, getObjectSchema, SCHEMAS, ADAPTER, AUTH, I18N, ROWS }; }); -vi.mock('@object-ui/i18n', () => ({ useObjectTranslation: () => I18N })); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); vi.mock('@object-ui/auth', () => { const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.rawPayloadGate.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.rawPayloadGate.test.tsx index 7adc5e6603..33b6304f9b 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.rawPayloadGate.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.rawPayloadGate.test.tsx @@ -107,7 +107,10 @@ const { approvalsApiStub, adapterFind, ADAPTER, AUTH, I18N } = vi.hoisted(() => return { approvalsApiStub, adapterFind, ADAPTER, AUTH, I18N }; }); -vi.mock('@object-ui/i18n', () => ({ useObjectTranslation: () => I18N })); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); vi.mock('@object-ui/auth', () => { const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.recordLink.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.recordLink.test.tsx index 2bcccd3849..51e2ddd67a 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.recordLink.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.recordLink.test.tsx @@ -107,7 +107,10 @@ const { adapterFind, approvalsApiStub, rows, ADAPTER, AUTH, I18N } = vi.hoisted( return { adapterFind, approvalsApiStub, rows, ADAPTER, AUTH, I18N }; }); -vi.mock('@object-ui/i18n', () => ({ useObjectTranslation: () => I18N })); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); vi.mock('@object-ui/auth', () => { const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.stepProgressVertical.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.stepProgressVertical.test.tsx index 513f9d90f7..96becedfa4 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.stepProgressVertical.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.stepProgressVertical.test.tsx @@ -141,7 +141,10 @@ const { approvalsApiStub, setFlowSteps, ADAPTER, AUTH, I18N } = vi.hoisted(() => return { approvalsApiStub, setFlowSteps, ADAPTER, AUTH, I18N }; }); -vi.mock('@object-ui/i18n', () => ({ useObjectTranslation: () => I18N })); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); vi.mock('@object-ui/auth', () => { const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); diff --git a/packages/app-shell/src/console/ai/LiveCanvas.test.tsx b/packages/app-shell/src/console/ai/LiveCanvas.test.tsx index 48ad81f6db..68dc1e9188 100644 --- a/packages/app-shell/src/console/ai/LiveCanvas.test.tsx +++ b/packages/app-shell/src/console/ai/LiveCanvas.test.tsx @@ -12,7 +12,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { LiveCanvas } from './LiveCanvas'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx b/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx index 430abcdece..5fe32fdde2 100644 --- a/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx @@ -20,7 +20,8 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { CreateWorkspaceDialog } from '../CreateWorkspaceDialog'; import { provisionProductionEnvironment } from '../provisionEnvironment'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx b/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx index b83df298f5..b28c4a516f 100644 --- a/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx @@ -19,7 +19,8 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.roleCap.test.tsx b/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.roleCap.test.tsx index 982ee44d8a..59fe6f3f5a 100644 --- a/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.roleCap.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.roleCap.test.tsx @@ -21,7 +21,8 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx b/packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx index 45b12294e8..ecd25b2299 100644 --- a/packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx @@ -13,7 +13,8 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx b/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx index b3eaa1ac8c..4f6f55986c 100644 --- a/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx @@ -26,7 +26,8 @@ import '@testing-library/jest-dom/vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx index 1559256331..0ff2d66052 100644 --- a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx @@ -27,7 +27,8 @@ vi.mock('@object-ui/auth', () => ({ createAuthenticatedFetch: () => authFetchSpy, })); -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectLabel: () => ({ fieldLabel: (_o: any, _n: any, l: any) => l, fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l, diff --git a/packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx b/packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx index ea316e22c1..dabff765ce 100644 --- a/packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useObjectActions.test.tsx @@ -29,7 +29,8 @@ vi.mock('react-router-dom', () => ({ useParams: () => ({ appName: 'crm' }), })); -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ // Echo the interpolated defaultValue so assertions read naturally; fall // back to the key when a test doesn't provide one. diff --git a/packages/app-shell/src/layout/__tests__/ActivityFeed.unknownKindFailsOpen-6816.test.tsx b/packages/app-shell/src/layout/__tests__/ActivityFeed.unknownKindFailsOpen-6816.test.tsx index 62e35d95ec..646a37823a 100644 --- a/packages/app-shell/src/layout/__tests__/ActivityFeed.unknownKindFailsOpen-6816.test.tsx +++ b/packages/app-shell/src/layout/__tests__/ActivityFeed.unknownKindFailsOpen-6816.test.tsx @@ -32,7 +32,8 @@ import type { ActivityItem, ActivityItemType } from '../activityItemType.js'; // Key-echoing stub: the assertions below address the filter badges by their // i18n key, so a stub returning the key verbatim is what makes them legible. -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ language: 'en', t: (key: string) => key }), })); diff --git a/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx b/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx index a2f0f8c4c8..48c6dc2a88 100644 --- a/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx @@ -11,7 +11,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ // Interpolates `{{name}}` from the options object (mirrors real i18next // closely enough for count-driven copy like `resetsWeeklyDays`) — a plain diff --git a/packages/app-shell/src/layout/__tests__/ChatDock.test.tsx b/packages/app-shell/src/layout/__tests__/ChatDock.test.tsx index f2d18fe29d..e066d044ef 100644 --- a/packages/app-shell/src/layout/__tests__/ChatDock.test.tsx +++ b/packages/app-shell/src/layout/__tests__/ChatDock.test.tsx @@ -11,7 +11,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { ChatDockPanel, ChatDockMobileSheet, type ChatDockState } from '../ChatDock'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/layout/__tests__/ConsoleChatbotFab.test.tsx b/packages/app-shell/src/layout/__tests__/ConsoleChatbotFab.test.tsx index 8652f2091a..879c191a42 100644 --- a/packages/app-shell/src/layout/__tests__/ConsoleChatbotFab.test.tsx +++ b/packages/app-shell/src/layout/__tests__/ConsoleChatbotFab.test.tsx @@ -3,7 +3,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { ConsoleChatbotFab } from '../ConsoleChatbotFab'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => { if (key === 'topbar.openAssistant') return `Open ${String(options?.name)} assistant`; diff --git a/packages/app-shell/src/layout/__tests__/CurrentOrganizationIndicator.test.tsx b/packages/app-shell/src/layout/__tests__/CurrentOrganizationIndicator.test.tsx index 45c38042c7..d6b419460e 100644 --- a/packages/app-shell/src/layout/__tests__/CurrentOrganizationIndicator.test.tsx +++ b/packages/app-shell/src/layout/__tests__/CurrentOrganizationIndicator.test.tsx @@ -32,7 +32,8 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, act } from '@testing-library/react'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.test.tsx b/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.test.tsx index ba1d2cfc6f..d6b31c2fef 100644 --- a/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.test.tsx +++ b/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.test.tsx @@ -14,7 +14,8 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (key: string, options?: Record) => String(options?.defaultValue ?? key), }), diff --git a/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx b/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx index d71a29273b..097c3fb246 100644 --- a/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx +++ b/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx @@ -11,7 +11,8 @@ import * as React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, }), diff --git a/packages/app-shell/src/preview/__tests__/DraftPreviewBar.test.tsx b/packages/app-shell/src/preview/__tests__/DraftPreviewBar.test.tsx index 1f71f6f410..cbc0040b2d 100644 --- a/packages/app-shell/src/preview/__tests__/DraftPreviewBar.test.tsx +++ b/packages/app-shell/src/preview/__tests__/DraftPreviewBar.test.tsx @@ -18,7 +18,8 @@ vi.mock('../PreviewModeContext', () => ({ markPreviewExit: vi.fn(), PREVIEW_QUERY_FLAG: 'preview', })); -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, }), diff --git a/packages/app-shell/src/views/DashboardView.modalTarget.test.tsx b/packages/app-shell/src/views/DashboardView.modalTarget.test.tsx index 4673d87c44..d43ecc222d 100644 --- a/packages/app-shell/src/views/DashboardView.modalTarget.test.tsx +++ b/packages/app-shell/src/views/DashboardView.modalTarget.test.tsx @@ -74,7 +74,8 @@ vi.mock('./MetadataInspector', () => ({ })); vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => ({}) })); vi.mock('../providers/ExpressionProvider', () => ({ useExpressionContext: () => ({ app: undefined }) })); -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (k: string) => k }), useObjectLabel: () => ({ dashboardLabel: ({ label, name }: any) => label ?? name, diff --git a/packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx b/packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx index 64cf55bee4..bcb0b44709 100644 --- a/packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx +++ b/packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx @@ -65,7 +65,8 @@ vi.mock('./MetadataInspector', () => ({ })); vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => ({}) })); vi.mock('../providers/ExpressionProvider', () => ({ useExpressionContext: () => ({ app: undefined }) })); -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (k: string) => k }), // Pass-through: the i18n bundle is a SEPARATE channel with its own tests, and // resolving through it here would let a bundle entry answer for the key this diff --git a/packages/app-shell/src/views/ReportView.dataSourceObjectKey.test.tsx b/packages/app-shell/src/views/ReportView.dataSourceObjectKey.test.tsx index fbf7e43784..f30d241229 100644 --- a/packages/app-shell/src/views/ReportView.dataSourceObjectKey.test.tsx +++ b/packages/app-shell/src/views/ReportView.dataSourceObjectKey.test.tsx @@ -90,7 +90,8 @@ vi.mock('./runtime-metadata-persistence', () => ({ persistRuntimeMetadata: vi.fn vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => ({}) })); vi.mock('../providers/ExpressionProvider', () => ({ useExpressionContext: () => ({ app: undefined }) })); vi.mock('@object-ui/auth', () => ({ useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }) })); -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (k: string) => k }), createSafeTranslation: (defaults: Record) => () => ({ t: (k: string) => defaults?.[k] ?? k, diff --git a/packages/app-shell/src/views/__tests__/DeclaredActionsBar.overrideAffordance.test.tsx b/packages/app-shell/src/views/__tests__/DeclaredActionsBar.overrideAffordance.test.tsx index 7d3d58f31f..6a7812b575 100644 --- a/packages/app-shell/src/views/__tests__/DeclaredActionsBar.overrideAffordance.test.tsx +++ b/packages/app-shell/src/views/__tests__/DeclaredActionsBar.overrideAffordance.test.tsx @@ -72,7 +72,8 @@ vi.mock('../../utils/getIcon', () => ({ * worth anything if the APPROVER NAMES actually land in it, so the assertions * below read the composed English sentence rather than a key name. */ -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectLabel: () => ({ actionLabel: (_o: unknown, _n: unknown, fallback: string) => fallback, actionConfirm: (_o: unknown, _n: unknown, fallback?: string) => fallback, diff --git a/packages/app-shell/src/views/__tests__/PageView.test.tsx b/packages/app-shell/src/views/__tests__/PageView.test.tsx index ca481093d8..5e445d7db5 100644 --- a/packages/app-shell/src/views/__tests__/PageView.test.tsx +++ b/packages/app-shell/src/views/__tests__/PageView.test.tsx @@ -32,7 +32,8 @@ vi.mock('@object-ui/auth', () => ({ createAuthenticatedFetch: () => authFetchSpy, })); -vi.mock('@object-ui/i18n', () => ({ +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), useObjectTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? o?.name ?? k }), useObjectLabel: () => ({ fieldLabel: (_o: any, _n: any, l: any) => l, diff --git a/scripts/__tests__/check-vi-mock-inherit.test.ts b/scripts/__tests__/check-vi-mock-inherit.test.ts index 176840ff96..c5903ad127 100644 --- a/scripts/__tests__/check-vi-mock-inherit.test.ts +++ b/scripts/__tests__/check-vi-mock-inherit.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { execFileSync } from 'node:child_process'; +import { afterAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -708,3 +708,286 @@ describe('wiring — the gate is reachable and every PR shape starts it', () => expect(yaml.indexOf(SCRIPT)).toBeGreaterThan(-1); }); }); + +// --------------------------------------------------------------------------- +// objectui#7337 — the `@object-ui/i18n` sweep +// --------------------------------------------------------------------------- + +/** The specifier objectui#7337 swept. Not in `COVERED_SPECIFIERS` yet — see below. */ +const I18N = '@object-ui/i18n'; + +/** Classify one factory in isolation against an arbitrary covered specifier. */ +function verdictFor(spec: string, factory: string) { + const sites = findCallSites(mockCall(spec, factory), { covered: [spec] }); + expect(sites, 'the fixture must produce exactly one call site').toHaveLength(1); + return sites[0]; +} + +describe('the generic argument NESTS — `vi.importActual>(…)`', () => { + /** + * The recogniser's optional generic was `<[^>]*>`, which stops at the FIRST + * `>`. Against `vi.importActual>('@object-ui/i18n')` + * it consumed `` and then failed on the `>` that + * follows, so the whole call went unmatched and the factory was reported as + * one that "never obtains the real module" — on code that obtains it and + * spreads it. + * + * That is the failure this gate's own header rules out by name, and it stayed + * invisible because the covered set was `@object-ui/react`, where nobody + * writes the spelling. Measured over the whole tree at the fix: 349 frozen + * across all 21 workspace specifiers became 344, and no site moved the other + * way. + */ + + const RECEIVER = `async () => { const actual = await OBTAIN; return { ...actual, X: Stub }; }`; + const withObtain = (generic: string) => RECEIVER.replace('OBTAIN', importActual(I18N, generic)); + + it('reads the NESTED generic the four real files write', () => { + expect(verdictFor(I18N, withObtain('>')).verdict).toBe('inherits'); + }); + + it('still reads the spellings that always worked — no generic, and a flat one', () => { + expect(verdictFor(I18N, withObtain('')).verdict).toBe('inherits'); + expect(verdictFor(I18N, withObtain('')).verdict).toBe('inherits'); + expect(verdictFor(I18N, withObtain(``)).verdict).toBe('inherits'); + }); + + it('reads a generic nested twice — the scan is balanced, not one level deep', () => { + expect(verdictFor(I18N, withObtain('>>')).verdict).toBe('inherits'); + }); + + it('an UNBALANCED angle bracket is not read as an obtain — it stays FROZEN', () => { + // The failure direction of an unforeseen spelling is the verdict the old + // regex already gave, never a false GREEN. + expect(verdictFor(I18N, withObtain('')).verdict).toBe('frozen'); + }); + + it('an importActual of a DIFFERENT specifier still does not inherit this one', () => { + const other = RECEIVER.replace('OBTAIN', importActual('@object-ui/auth', '>')); + expect(verdictFor(I18N, other).verdict).toBe('frozen'); + }); + + it('THE FOUR REAL FILES: each obtains and spreads through the nested spelling', () => { + // Pinned against the files on disk, not against a reconstruction: these are + // the four the old regex called frozen, and a future edit reddens here. + const misread = [ + 'packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindError.test.tsx', + 'packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindErrorLocale.test.tsx', + 'packages/app-shell/src/layout/__tests__/AppSwitcher.publishState.test.tsx', + 'packages/app-shell/src/preview/__tests__/UnpublishedAppBar.test.tsx', + ]; + for (const file of misread) { + const source = fs.readFileSync(path.join(repoRoot, file), 'utf8'); + expect(source, `${file} no longer writes the nested-generic obtain`).toContain( + `${'importActual'}>`, + ); + const judged = findCallSites(source, { covered: [I18N] }).filter((s: { scope: string }) => s.scope === 'covered'); + expect(judged.map((s: { verdict: string }) => s.verdict), file).toEqual(['inherits']); + } + }); +}); + +describe('the sweep — every `@object-ui/i18n` factory inherits, bar the one held file', () => { + /** + * objectui#7337 converted 29 frozen factories and deleted a 30th + * (`apps/console/dev/__tests__/setup/common-mocks.ts`, a helper with zero + * importers repo-wide). The 31st — `DeclaredActionsBar.test.tsx` — is held by + * open PR #7846 and could not be touched, so `COVERED_SPECIFIERS` was NOT + * widened: flipping it while a frozen factory remains turns `main` red. + * + * The assertions below are the ratchet in the meantime, and they are + * deliberately ONE-DIRECTIONAL. They redden when a NEW frozen factory appears + * — the defect — and stay green when the held one is fixed, so nobody's + * unrelated PR pays for finishing this. + */ + + /** Held by open PR #7846 at the time of the sweep. */ + const HELD = 'packages/app-shell/src/views/__tests__/DeclaredActionsBar.test.tsx'; + + const swept = () => scan(repoRoot, { covered: [I18N], floors: {} }); + + it('no `@object-ui/i18n` factory outside the held file freezes the surface', () => { + const result = swept(); + const frozen = result.frozen.map((f: { file: string }) => f.file).filter((f: string) => f !== HELD); + expect(frozen, 'convert these to the obtain-and-spread form before adding the specifier').toEqual([]); + expect(result.unreadable, 'a factory the gate cannot read is never a pass').toEqual([]); + }); + + it('walked a real population — a collapsed scan cannot read as a swept one', () => { + // Same discipline as `FLOORS`: this describe's green is a claim about 92 + // call sites, so the count is asserted rather than assumed. + const census = swept().census; + expect(census.covered).toBeGreaterThan(60); + expect(census.inherits).toBeGreaterThan(60); + expect(census.covered - census.inherits - census.automock).toBeLessThanOrEqual(1); + }); + + it('the specifier is NOT in COVERED_SPECIFIERS yet, and this is the reason', () => { + // The follow-up, in one line: once PR #7846 lands, convert + // `DeclaredActionsBar.test.tsx:65`, DELETE this case, and add the specifier + // to `COVERED_SPECIFIERS`. Until then the flip reds `main` on merge. + expect( + COVERED_SPECIFIERS, + 'a frozen @object-ui/i18n factory still exists — widening the set now reds main', + ).not.toContain(I18N); + }); + + it('the zero-importer mock helper is gone, not merely unreferenced', () => { + // The needle is ASSEMBLED, for the reason "Fixture discipline" gives above: + // spelt whole it would appear in this file and the search would find + // itself. Measured — the first draft of this case failed exactly that way. + const needle = `applyCommon${'ConsoleMocks'}`; + expect(fs.existsSync(path.join(repoRoot, 'apps/console/dev/__tests__/setup/common-mocks.ts'))).toBe(false); + // `git grep` exits 1 on no match, which is the PASSING case, so the run is + // read rather than thrown: `execFileSync` would turn the pass into an error. + const hits = spawnSync('git', ['grep', '-l', needle, '--', '.'], { cwd: repoRoot, encoding: 'utf8' }); + expect((hits.stdout ?? '').trim(), 'the deleted helper is named again somewhere').toBe(''); + expect(hits.status, 'git grep itself failed — the search never ran').toBe(1); + }); +}); + +describe('THE DEATH — a frozen factory kills the file at COLLECTION, not in a test', () => { + /** + * The gate's verdict is a prediction about what vitest does. This case makes + * the prediction and then checks it by running vitest for real, over a + * throwaway package whose "next export" is the one added the day after the + * factory was written — objectui#7337's own reproduction, minus the repo. + * + * Three legs, and the third is what stops the first from being vacuous: + * + * 1. FROZEN factory + a MODULE-SCOPE read -> the suite never collects. + * `Tests no tests`: zero failed assertions, which is why objectui#6768 + * records that this reads as flake and bills the wrong author. + * 2. INHERITING factory, same read -> collects and passes. + * 3. FROZEN factory + a LAZY read -> collects, and fails as an + * ordinary assertion pointing at the culprit. So leg 1 is measuring the + * MODULE-SCOPE read, not merely the presence of a frozen factory — + * without leg 3 a suite that failed for any reason at all would satisfy + * it. + */ + + /** A specifier that exists only inside the fixture tree. */ + const SPEC = '@fixture/i18n'; + + const REAL_MODULE = [ + `export const useObjectTranslation = () => ({ t: (k) => k });`, + `// The export added the day AFTER every frozen factory below was written.`, + `export const createSafeTranslation = (defaults) => () => ({ t: (k) => defaults?.[k] ?? k });`, + '', + ].join('\n'); + + const FROZEN = `() => ({ useObjectTranslation: () => ({ t: (k) => k }) })`; + const INHERITING = `async (importOriginal) => ({ ...(await importOriginal()), useObjectTranslation: () => ({ t: (k) => k }) })`; + + const EAGER = [ + `import { createSafeTranslation } from ${Q}${SPEC}${Q};`, + `export const DISCARD_GUARD = createSafeTranslation({ discard: ${Q}Discard?${Q} });`, + '', + ].join('\n'); + + const LAZY = [ + `import { createSafeTranslation } from ${Q}${SPEC}${Q};`, + `export const discardGuard = () => createSafeTranslation({ discard: ${Q}Discard?${Q} });`, + '', + ].join('\n'); + + const suite = (factory: string, importLine: string, body: string) => + [`import { expect, it, vi } from ${Q}vitest${Q};`, mockCall(SPEC, factory), importLine, body, ''].join('\n'); + + const roots: string[] = []; + + /** + * A throwaway package tree. `vitest` is resolved by walking UP from the + * fixture, so one symlink is everything it borrows from this repo; the mocked + * specifier is a REAL package inside the fixture's own `node_modules`, which + * is what makes it a bare specifier the gate judges rather than a relative + * one it declines to. + */ + function fixturePackage(files: Record) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vi-mock-collection-death-')); + roots.push(root); + const pkg = path.join(root, 'node_modules', SPEC); + fs.mkdirSync(pkg, { recursive: true }); + fs.symlinkSync(path.join(repoRoot, 'node_modules/vitest'), path.join(root, 'node_modules/vitest')); + fs.writeFileSync( + path.join(pkg, 'package.json'), + `${JSON.stringify({ name: SPEC, version: '0.0.0', type: 'module', main: 'index.mjs' }, null, 2)}\n`, + ); + fs.writeFileSync(path.join(pkg, 'index.mjs'), REAL_MODULE); + for (const [rel, body] of Object.entries(files)) fs.writeFileSync(path.join(root, rel), body); + return root; + } + + /** The nested run, with this run's own VITEST_* wiring kept out of its env. */ + function runVitest(root: string) { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined || key === 'VITEST' || key.startsWith('VITEST_')) continue; + env[key] = value; + } + const run = spawnSync(path.join(repoRoot, 'node_modules/.bin/vitest'), ['run', '--root', root], { + cwd: root, + encoding: 'utf8', + env, + }); + return { status: run.status, output: `${run.stdout ?? ''}${run.stderr ?? ''}` }; + } + + afterAll(() => { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('the gate PREDICTS the two outcomes before either is run', () => { + expect(verdictFor(SPEC, FROZEN).verdict).toBe('frozen'); + expect(verdictFor(SPEC, INHERITING).verdict).toBe('inherits'); + }); + + it('LEG 1 — frozen factory, module-scope read: the file dies during COLLECTION', () => { + const root = fixturePackage({ + 'consumer.mjs': EAGER, + 'frozen.test.mjs': suite( + FROZEN, + `import { DISCARD_GUARD } from ${Q}./consumer.mjs${Q};`, + `it(${Q}never runs${Q}, () => { expect(DISCARD_GUARD).toBeTypeOf(${Q}function${Q}); });`, + ), + }); + const { status, output } = runVitest(root); + expect(status, output).not.toBe(0); + expect(output).toContain('No "createSafeTranslation" export is defined on the "@fixture/i18n" mock'); + expect(output, 'the suite failed as a SUITE, before any test existed').toMatch(/Failed Suites\s+1/); + // The signature objectui#6768 measured: a red run with nothing to point at. + expect(output, 'zero failed assertions is what makes this read as flake').toMatch(/Tests\s+no tests/); + }, 120_000); + + it('LEG 2 — the converted factory, same read: it collects and passes', () => { + const root = fixturePackage({ + 'consumer.mjs': EAGER, + 'inheriting.test.mjs': suite( + INHERITING, + `import { DISCARD_GUARD } from ${Q}./consumer.mjs${Q};`, + `it(${Q}runs${Q}, () => { expect(DISCARD_GUARD).toBeTypeOf(${Q}function${Q}); });`, + ), + }); + const { status, output } = runVitest(root); + expect(status, output).toBe(0); + expect(output).toMatch(/Tests\s+1 passed/); + }, 120_000); + + it('LEG 3 — the NON-VACUITY control: a lazy read fails as an ordinary test', () => { + const root = fixturePackage({ + 'lazy-consumer.mjs': LAZY, + 'lazy.test.mjs': suite( + FROZEN, + `import { discardGuard } from ${Q}./lazy-consumer.mjs${Q};`, + `it(${Q}collects, then fails${Q}, () => { expect(discardGuard()).toBeTypeOf(${Q}function${Q}); });`, + ), + }); + const { status, output } = runVitest(root); + expect(status, output).not.toBe(0); + // Same missing export, same frozen factory -- and a completely different + // failure shape, because the read is no longer at module scope. + expect(output).toContain('No "createSafeTranslation" export is defined on the "@fixture/i18n" mock'); + expect(output, 'a lazy read collects, so the failure is an assertion').toMatch(/Tests\s+1 failed/); + expect(output).not.toMatch(/Failed Suites/); + }, 120_000); +}); diff --git a/scripts/check-vi-mock-inherit.mjs b/scripts/check-vi-mock-inherit.mjs index 174dc9c4bf..39d73f4b43 100644 --- a/scripts/check-vi-mock-inherit.mjs +++ b/scripts/check-vi-mock-inherit.mjs @@ -311,6 +311,64 @@ function readInitialiser(body, from) { /** The synthetic stand-in for `vi.importActual()`. */ const OBTAIN_TOKEN = '__OBTAINED_ORIGINAL__'; +/** + * Every `vi.importActual()` in `text`, as `[start, end)` spans. + * + * Deliberately NOT one regular expression. The optional generic argument + * NESTS -- `vi.importActual>('@object-ui/i18n')` is + * the spelling four factories in this tree write -- and the `<[^>]*>` this + * replaces stopped at the FIRST `>`: it consumed `` + * and then failed against the `>` that follows, so the whole call went + * unrecognised and the factory was reported as one that "never obtains the + * real module" -- on code that obtains it and spreads it. + * + * That is the failure this gate's header rules out by name: a recogniser that + * calls correct code broken gets the gate deleted rather than fixed. Measured + * on this tree at the time of the fix, with `@object-ui/i18n` as the covered + * specifier, 35 frozen became 31, and each of the four that moved spreads a + * binding initialised from this call (objectui#7337). Nothing moves the other + * way: this only ever ADDS a way to recognise the obtain, so no site that read + * `inherits` can start reading `frozen`. + * + * Angle brackets are scanned BALANCED. Anything that does not parse as the + * exact call shape is skipped, so the worst case for an unforeseen spelling is + * the verdict the regex already gave. + */ +export function importActualSpans(text, specifier) { + const spans = []; + const head = /\bvi\s*\.\s*importActual\s*/g; + let m; + while ((m = head.exec(text)) !== null) { + let i = m.index + m[0].length; + if (text[i] === '<') { + let depth = 0; + for (; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>' && --depth === 0) { + i++; + break; + } + } + if (depth !== 0) continue; // unbalanced -- not a call this gate can read + while (/\s/.test(text[i] ?? '')) i++; + } + if (text[i] !== '(') continue; + i++; + while (/\s/.test(text[i] ?? '')) i++; + const quote = text[i]; + if (quote !== "'" && quote !== '"' && quote !== '`') continue; + if (text.slice(i + 1, i + 1 + specifier.length) !== specifier) continue; + let j = i + 1 + specifier.length; + if (text[j] !== quote) continue; + j++; + while (/\s/.test(text[j] ?? '')) j++; + if (text[j] !== ')') continue; + spans.push([m.index, j + 1]); + head.lastIndex = j + 1; + } + return spans; +} + /** * Read the head of a factory argument: its parameter names, and where its body * starts. Returns `null` when the argument is not a function literal at all. @@ -363,26 +421,16 @@ export function classifyFactory(masked, literal, start, end, specifier) { // then blank literal content -- in that order, because the specifier the // first pass matches on IS literal content. const bodyStart = head.bodyStart; - const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const importActualRe = new RegExp( - `\\bvi\\s*\\.\\s*importActual\\s*(?:<[^>]*>)?\\s*\\(\\s*(['"\`])${escaped}\\1\\s*\\)`, - 'g', - ); let body = ''; for (let i = bodyStart; i < end; i++) body += literal[i] ? ' ' : masked[i]; // ...but the importActual specifier has to survive the blanking to be // matched, so run that pass over the un-blanked body and pad to length. const rawBody = masked.slice(bodyStart, end); - let obtainedViaImportActual = false; - const marks = []; let m; - importActualRe.lastIndex = 0; - while ((m = importActualRe.exec(rawBody)) !== null) { - if (literal[bodyStart + m.index]) continue; // the call itself is quoted - obtainedViaImportActual = true; - marks.push([m.index, m.index + m[0].length]); - } + // A call whose own `vi` token sits inside a string literal is prose, not code. + const marks = importActualSpans(rawBody, specifier).filter(([from]) => !literal[bodyStart + from]); + const obtainedViaImportActual = marks.length > 0; for (const [from, to] of marks) { body = body.slice(0, from) + OBTAIN_TOKEN.padEnd(to - from, ' ') + body.slice(to); } diff --git a/scripts/check-vi-mock-specifiers.mjs b/scripts/check-vi-mock-specifiers.mjs index 846c64f4df..300449e6a5 100644 --- a/scripts/check-vi-mock-specifiers.mjs +++ b/scripts/check-vi-mock-specifiers.mjs @@ -95,19 +95,23 @@ * ## Why the walk is not restricted to test-NAMED files * * The obvious population is the `*.test.*` / `*.spec.*` naming. It has a hole, - * measured on this tree: TWO files carrying a real call site match no such - * suffix, and ONE of those matches no test-file naming convention at all, not - * even a `__tests__/` directory -- + * measured on this tree: ONE file carrying a real call site matches no such + * suffix, and it matches no test-file naming convention at all, not even a + * `__tests__/` directory -- * - * apps/console/dev/__tests__/setup/common-mocks.ts (suffix: no, dir: yes) * vitest.setup.base.ts (neither) * - * It was THREE until objectui#3240. `packages/plugin-map/vitest.setup.ts` also - * carried one -- a `maplibre-gl` mock duplicating the one in - * `vitest.setup.base.ts` -- and only that package's own vitest config ever - * loaded it, so it never ran under the invocation CI uses. Deleting the config - * took the copy with it. The hole this walk exists for is unchanged: a setup - * file is still exactly where a repo-wide mock gets written. + * It was THREE until objectui#3240 and TWO until objectui#7337, and both + * departures are the same story. `packages/plugin-map/vitest.setup.ts` carried + * a `maplibre-gl` mock duplicating the one in `vitest.setup.base.ts`, and only + * that package's own vitest config ever loaded it, so it never ran under the + * invocation CI uses; deleting the config took the copy with it. + * `apps/console/dev/__tests__/setup/common-mocks.ts` carried a frozen + * `@object-ui/i18n` factory in a helper with ZERO importers repo-wide, and the + * i18n sweep deleted it rather than leave dead code wearing the shape. + * The hole this walk exists for is unchanged: a setup file -- and a mock helper + * -- is still exactly where a repo-wide mock gets written, and the walk has to + * be the thing that decides that, not the filename. * * A setup file is exactly where a repo-wide mock gets written, and a mock helper * shared by a directory of suites is exactly where one goes unreviewed. So the From 1d447254d49cb11091ebb03a528d5b512fa04baa Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 6 Sep 2026 01:52:50 +0000 Subject: [PATCH 2/2] test(scripts): read the nested vitest's verdict from its JSON reporter, not from a coloured summary (objectui#7337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI run 34003883330 (job 101407488095) failed three assertions in the death pin on output that visibly contained the text they were matching. Under GitHub Actions the nested vitest colours its summary, so the real bytes are `Tests ` + SGR + `1 failed` + SGR + ` (1)` and `\s+` matches no escape sequence. The box this was written on never colours vitest under any env, so the shape was unreachable locally. - the three legs now decide on the JSON reporter's structured counts, so no verdict depends on how vitest PRINTS. Leg 1 asserts total=0 / assertions=0 with the collection error on the SUITE; leg 3 asserts total=1 / assertions=1 with no suite message. That pair is the discrimination, and it is sharper than the prose was, not looser; - the human-readable lines the card quotes are still asserted, on an ANSI-stripped copy; - a new case replays the exact bytes from that CI job: the old regex does not match them, the stripped form does; - `GITHUB_ACTIONS` is dropped from the child's env. Legs 1 and 3 fail on purpose, and with it set the child emitted `::error file=…` annotations into the parent's own CI log — visible in that same run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KDq78vMMSzCGWGmhUYBabh --- .../__tests__/check-vi-mock-inherit.test.ts | 129 ++++++++++++++---- 1 file changed, 106 insertions(+), 23 deletions(-) diff --git a/scripts/__tests__/check-vi-mock-inherit.test.ts b/scripts/__tests__/check-vi-mock-inherit.test.ts index c5903ad127..42e7613efb 100644 --- a/scripts/__tests__/check-vi-mock-inherit.test.ts +++ b/scripts/__tests__/check-vi-mock-inherit.test.ts @@ -918,19 +918,71 @@ describe('THE DEATH — a frozen factory kills the file at COLLECTION, not in a return root; } - /** The nested run, with this run's own VITEST_* wiring kept out of its env. */ + /** + * ANSI SGR sequences, built from the escape's CODE POINT — a raw control byte + * in this source is what `pnpm check:control-bytes` exists to refuse. + */ + const SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + const stripAnsi = (text: string) => text.replace(SGR, ''); + + /** + * The nested run. + * + * Three things about the child are DELIBERATE, and the first two are repairs + * (CI run 34003883330, job 101407488095, where three assertions here failed + * on output that visibly contained the text they were matching): + * + * 1. **The verdict comes from the JSON reporter, not from the summary.** + * Under GitHub Actions the child colours its output, so the summary line + * is really `Tests ` + SGR + `1 failed` + SGR + ` (1)` and `\s+` matches + * no escape sequence. Pinning a human-readable summary through a regex + * was the fragile part; the counts are read from structured data now and + * the prose is only checked after `stripAnsi`, which is belt to that + * brace. Reproduced byte-for-byte in `the summary matcher survives the + * colour CI adds` below. + * 2. **`GITHUB_ACTIONS` is removed from the child's env.** Legs 1 and 3 + * fail ON PURPOSE, and with that variable set the child switches on + * vitest's github-actions reporter and writes `::error file=…` + * annotations — which the CI log shows it did, decorating the parent's + * own run with failures from a fixture that is behaving correctly. + * 3. `NO_COLOR` asks for uncoloured output. It is not relied on: the + * stripping above is what makes the assertions true either way. + */ function runVitest(root: string) { - const env: Record = {}; + const env: Record = { NO_COLOR: '1' }; for (const [key, value] of Object.entries(process.env)) { if (value === undefined || key === 'VITEST' || key.startsWith('VITEST_')) continue; + if (key === 'GITHUB_ACTIONS' || key === 'NO_COLOR' || key === 'FORCE_COLOR') continue; env[key] = value; } - const run = spawnSync(path.join(repoRoot, 'node_modules/.bin/vitest'), ['run', '--root', root], { - cwd: root, - encoding: 'utf8', - env, - }); - return { status: run.status, output: `${run.stdout ?? ''}${run.stderr ?? ''}` }; + const reportAt = path.join(root, 'vitest-report.json'); + const run = spawnSync( + path.join(repoRoot, 'node_modules/.bin/vitest'), + ['run', '--root', root, '--reporter=default', '--reporter=json', `--outputFile.json=${reportAt}`], + { cwd: root, encoding: 'utf8', env }, + ); + const output = `${run.stdout ?? ''}${run.stderr ?? ''}`; + if (!fs.existsSync(reportAt)) { + throw new Error(`the nested vitest wrote no JSON report -- it did not run:\n${output}`); + } + const report = JSON.parse(fs.readFileSync(reportAt, 'utf8')); + const suite = report.testResults?.[0] ?? {}; + return { + status: run.status, + plain: stripAnsi(output), + /** Structured, so no assertion here depends on how vitest PRINTS. */ + facts: { + success: report.success, + total: report.numTotalTests, + passed: report.numPassedTests, + failed: report.numFailedTests, + suiteStatus: suite.status, + /** 0 when the file never collected: there was no test to run. */ + assertions: suite.assertionResults?.length ?? 0, + /** A suite-level message is where a COLLECTION error lands. */ + suiteMessage: String(suite.message ?? ''), + }, + }; } afterAll(() => { @@ -942,6 +994,19 @@ describe('THE DEATH — a frozen factory kills the file at COLLECTION, not in a expect(verdictFor(SPEC, INHERITING).verdict).toBe('inherits'); }); + it('the summary matcher survives the colour CI adds', () => { + // The exact bytes from CI run 34003883330, job 101407488095, rebuilt from + // the escape's code point. Under GitHub Actions the child colours its + // summary, so `Tests ` and `1 failed` are separated by SGR sequences rather + // than by whitespace -- which is why three assertions here failed on output + // that visibly contained the text they were matching. + const e = String.fromCharCode(27); + const asCiPrinted = `${e}[2m Tests ${e}[22m ${e}[1m${e}[31m1 failed${e}[39m${e}[22m${e}[90m (1)${e}[39m`; + expect(asCiPrinted, 'the historical defect, reproduced').not.toMatch(/Tests\s+1 failed/); + expect(stripAnsi(asCiPrinted), 'and what this file matches on now').toMatch(/Tests\s+1 failed/); + expect(stripAnsi(asCiPrinted)).toBe(' Tests 1 failed (1)'); + }); + it('LEG 1 — frozen factory, module-scope read: the file dies during COLLECTION', () => { const root = fixturePackage({ 'consumer.mjs': EAGER, @@ -951,12 +1016,21 @@ describe('THE DEATH — a frozen factory kills the file at COLLECTION, not in a `it(${Q}never runs${Q}, () => { expect(DISCARD_GUARD).toBeTypeOf(${Q}function${Q}); });`, ), }); - const { status, output } = runVitest(root); - expect(status, output).not.toBe(0); - expect(output).toContain('No "createSafeTranslation" export is defined on the "@fixture/i18n" mock'); - expect(output, 'the suite failed as a SUITE, before any test existed').toMatch(/Failed Suites\s+1/); - // The signature objectui#6768 measured: a red run with nothing to point at. - expect(output, 'zero failed assertions is what makes this read as flake').toMatch(/Tests\s+no tests/); + const { status, plain, facts } = runVitest(root); + expect(status, plain).not.toBe(0); + // STRUCTURED, so nothing here depends on how vitest prints. The suite was + // found and failed, and NO test inside it ever existed to be run: that is + // collection death, and `total: 0` is what leg 3 will contradict. + expect(facts.suiteStatus).toBe('failed'); + expect(facts.total, 'a collected file would report its tests').toBe(0); + expect(facts.assertions, 'no test ran, so there is nothing to blame').toBe(0); + expect(facts.failed, 'the signature objectui#6768 measured: ZERO failed assertions').toBe(0); + expect(facts.suiteMessage, 'the collection error lands on the SUITE').toContain( + 'No "createSafeTranslation" export is defined on the "@fixture/i18n" mock', + ); + // ...and the human-readable half the card quotes, after ANSI is stripped. + expect(plain).toMatch(/Failed Suites\s+1/); + expect(plain, 'zero failed assertions is what makes this read as flake').toMatch(/Tests\s+no tests/); }, 120_000); it('LEG 2 — the converted factory, same read: it collects and passes', () => { @@ -968,9 +1042,12 @@ describe('THE DEATH — a frozen factory kills the file at COLLECTION, not in a `it(${Q}runs${Q}, () => { expect(DISCARD_GUARD).toBeTypeOf(${Q}function${Q}); });`, ), }); - const { status, output } = runVitest(root); - expect(status, output).toBe(0); - expect(output).toMatch(/Tests\s+1 passed/); + const { status, plain, facts } = runVitest(root); + expect(status, plain).toBe(0); + expect(facts.success).toBe(true); + expect(facts.total).toBe(1); + expect(facts.passed).toBe(1); + expect(plain).toMatch(/Tests\s+1 passed/); }, 120_000); it('LEG 3 — the NON-VACUITY control: a lazy read fails as an ordinary test', () => { @@ -982,12 +1059,18 @@ describe('THE DEATH — a frozen factory kills the file at COLLECTION, not in a `it(${Q}collects, then fails${Q}, () => { expect(discardGuard()).toBeTypeOf(${Q}function${Q}); });`, ), }); - const { status, output } = runVitest(root); - expect(status, output).not.toBe(0); + const { status, plain, facts } = runVitest(root); + expect(status, plain).not.toBe(0); // Same missing export, same frozen factory -- and a completely different - // failure shape, because the read is no longer at module scope. - expect(output).toContain('No "createSafeTranslation" export is defined on the "@fixture/i18n" mock'); - expect(output, 'a lazy read collects, so the failure is an assertion').toMatch(/Tests\s+1 failed/); - expect(output).not.toMatch(/Failed Suites/); + // shape, because the read is no longer at module scope. THESE THREE are + // what stop leg 1 from being satisfied by any red run at all: the file + // COLLECTED, one test existed, and the failure is an assertion. + expect(facts.total, 'the file collected, so its test exists').toBe(1); + expect(facts.assertions, 'and it ran -- leg 1 reports 0 here').toBe(1); + expect(facts.failed).toBe(1); + expect(facts.suiteMessage, 'nothing failed at COLLECTION this time').toBe(''); + expect(plain).toContain('No "createSafeTranslation" export is defined on the "@fixture/i18n" mock'); + expect(plain, 'a lazy read collects, so the failure is an assertion').toMatch(/Tests\s+1 failed/); + expect(plain).not.toMatch(/Failed Suites/); }, 120_000); });