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 af9344499c..d34fc6e281 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'; @@ -719,3 +719,369 @@ 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; + } + + /** + * 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 = { 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 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(() => { + 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('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, + '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, 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', () => { + 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, 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', () => { + 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, plain, facts } = runVitest(root); + expect(status, plain).not.toBe(0); + // Same missing export, same frozen factory -- and a completely different + // 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); +}); 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