From 012cbd902f4fd10466a2400a7fd646f1ef091806 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 11 Nov 2026 08:23:56 +0000 Subject: [PATCH] test(web): restore feature owners in browser specs (#46025) --- .../__tests__/delete-agent.browser.spec.tsx | 18 ++-- .../__tests__/publish-bar.browser.spec.tsx | 44 +++++---- .../logs/__tests__/page.browser.spec.tsx | 42 ++++---- .../nested-dialog-backdrops.browser.spec.tsx | 81 +++++++++------- .../__tests__/focus.browser.spec.tsx | 22 +++-- .../__tests__/model-settings.browser.spec.tsx | 52 +++++----- .../sync-policy-field.browser.spec.tsx | 96 +++++++++---------- web/scripts/check-browser-spec-policy.ts | 84 ---------------- 8 files changed, 190 insertions(+), 249 deletions(-) diff --git a/web/features/agent-v2/__tests__/delete-agent.browser.spec.tsx b/web/features/agent-v2/__tests__/delete-agent.browser.spec.tsx index 96406bb5fcd..4f97357079f 100644 --- a/web/features/agent-v2/__tests__/delete-agent.browser.spec.tsx +++ b/web/features/agent-v2/__tests__/delete-agent.browser.spec.tsx @@ -8,6 +8,7 @@ import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { AgentPermission } from '@/features/agent-v2/acl' import agentRoster from '@/i18n/locales/en-US/agent-roster.json' +import app from '@/i18n/locales/en-US/app.json' import common from '@/i18n/locales/en-US/common.json' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' @@ -19,16 +20,8 @@ const api = vi.hoisted(() => ({ agents: vi.fn(), delete: vi.fn(), })) -vi.mock('react-i18next', async (importOriginal) => importOriginal()) -vi.mock('@/features/agent-v2/permissions', () => ({ - useCanCreateAgents: () => false, - useCanImportAgents: () => false, -})) -vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => path })) -vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: () => '' }) })) -vi.mock('@/app/components/app/use-export-app-dsl', () => ({ - useExportAppDsl: () => ({ exportAppDsl: vi.fn(), isExporting: false }), -})) +vi.unmock('react-i18next') +// boundary: generated Console transport is the server edge below the real roster owners. vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const { withConsoleResponses } = await import('@/test/console/service-responses') @@ -80,7 +73,7 @@ async function setup(initialAgents: AgentAppPartial[]) { const i18n = createInstance() await i18n.init({ lng: 'en-US', - resources: { 'en-US': { agentRoster, common } }, + resources: { 'en-US': { agentRoster, app, common } }, keySeparator: false, interpolation: { escapeValue: false }, }) @@ -97,6 +90,9 @@ async function setup(initialAgents: AgentAppPartial[]) { , ) await expect.element(screen.getByRole('list', { name: 'Agent list', exact: true })).toBeVisible() + await expect + .element(screen.getByRole('link', { name: 'Learn more about Agent' })) + .toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/build/new-agent/overview') const actions = (name: string) => screen.getByRole('button', { name: `More actions for ${name}` }) const openDelete = async (name: string) => { await screen.getByRole('link', { name, exact: true }).hover() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.browser.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.browser.spec.tsx index bb4f2935ab6..e0b8cdd5be2 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.browser.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.browser.spec.tsx @@ -1,11 +1,17 @@ import { zAgentAppComposerResponse } from '@dify/contracts/api/console/agent/zod.gen' import { QueryClientProvider } from '@tanstack/react-query' +import { createInstance } from 'i18next' import { createStore, Provider } from 'jotai' import { useState } from 'react' +import { I18nextProvider } from 'react-i18next' import { userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import agentV2 from '@/i18n/locales/en-US/agent-v-2.json' +import billing from '@/i18n/locales/en-US/billing.json' +import common from '@/i18n/locales/en-US/common.json' +import workflowHistory from '@/i18n/locales/en-US/workflow-history.json' import { consoleQuery } from '@/service/console' import { createSystemFeaturesFixture } from '@/test/console/system-features' import { createAgentFixture } from '@/test/fixtures/agent' @@ -13,7 +19,8 @@ import { createAppSiteFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' import { AgentConfigurePublishBar } from '../publish-bar' -vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn() } })) +vi.unmock('react-i18next') +// boundary: generated Console transport supplies the referenced-workflow server response. vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const { withConsoleResponses } = await import('@/test/console/service-responses') @@ -25,16 +32,17 @@ vi.mock('@/service/console', async (importOriginal) => { } }) -vi.mock('@/hooks/use-format-time-from-now', () => ({ - useFormatTimeFromNow: () => ({ formatTimeFromNow: () => 'just now' }), -})) -vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: () => '' }) })) -vi.mock('../../version-restore', () => ({ AgentVersionRestore: () => null })) - -const successTitle = 'agentV2.agentDetail.configure.publishSuccess.updateTitle' -const dismissLabel = 'agentV2.agentDetail.configure.publishSuccess.dismiss' +const successTitle = 'Changes published' +const dismissLabel = 'Dismiss publish guidance' async function setup() { + const i18n = createInstance() + await i18n.init({ + lng: 'en-US', + resources: { 'en-US': { agentV2, billing, common, workflowHistory } }, + keySeparator: false, + interpolation: { escapeValue: false }, + }) const queryClient = createTestQueryClient() const store = createStore() const input = { params: { agent_id: 'agent-1' } } @@ -98,7 +106,11 @@ async function setup() { ) } - const screen = await render() + const screen = await render( + + + , + ) return { screen, completePublish, queryClient } } @@ -110,15 +122,15 @@ it('preserves keyboard focus through loading, publication, and dismissal', async const editor = screen.getByRole('textbox', { name: 'Other editor' }) await editor.click() await userEvent.tab() - await expect.element(screen.getByRole('button', { name: /versionHistory$/ })).toHaveFocus() + await expect.element(screen.getByRole('button', { name: 'Open version history' })).toHaveFocus() await userEvent.tab() - const publish = screen.getByRole('button', { name: /agentV2.agentDetail.publish/ }) + const publish = screen.getByRole('button', { name: 'Publish', exact: true }) await expect.element(publish).toHaveFocus() const bar = publish.element().closest('[tabindex="-1"]')! const liveRegion = bar.querySelector('[role="status"][aria-atomic="true"]')! expect(liveRegion.textContent).toBe('') await userEvent.keyboard('{Enter}') - await expect.element(screen.getByRole('button', { name: /publishing/ })).toHaveFocus() + await expect.element(screen.getByRole('button', { name: 'Publishing…' })).toHaveFocus() completePublish() await expect.poll(() => liveRegion.textContent).toBe(successTitle) await expect.poll(() => document.activeElement).toBe(bar) @@ -127,15 +139,15 @@ it('preserves keyboard focus through loading, publication, and dismissal', async const dismiss = screen.getByRole('button', { name: dismissLabel }) await expect.element(dismiss).toHaveFocus() await userEvent.tab() - await expect.element(screen.getByRole('link', { name: /accessMethods$/ })).toHaveFocus() + await expect.element(screen.getByRole('link', { name: 'View all access methods' })).toHaveFocus() await userEvent.tab() - await expect.element(screen.getByRole('link', { name: /openWebApp$/ })).toHaveFocus() + await expect.element(screen.getByRole('link', { name: 'Open Web App' })).toHaveFocus() await userEvent.tab({ shift: true }) await userEvent.tab({ shift: true }) await userEvent.keyboard('{Enter}') await expect.poll(() => document.activeElement).toBe(bar) expect(liveRegion.textContent).toBe('') await userEvent.tab() - await expect.element(screen.getByRole('button', { name: /versionHistory$/ })).toHaveFocus() + await expect.element(screen.getByRole('button', { name: 'Open version history' })).toHaveFocus() queryClient.clear() }) diff --git a/web/features/agent-v2/agent-detail/logs/__tests__/page.browser.spec.tsx b/web/features/agent-v2/agent-detail/logs/__tests__/page.browser.spec.tsx index bf55b4484e5..65d60a46875 100644 --- a/web/features/agent-v2/agent-detail/logs/__tests__/page.browser.spec.tsx +++ b/web/features/agent-v2/agent-detail/logs/__tests__/page.browser.spec.tsx @@ -2,9 +2,14 @@ import type { AgentLogListResponse, AgentLogSourceListResponse, } from '@dify/contracts/api/console/agent/types.gen' -import { QueryClient } from '@tanstack/react-query' +import { createInstance } from 'i18next' +import { I18nextProvider } from 'react-i18next' import { page } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' +import agentV2 from '@/i18n/locales/en-US/agent-v-2.json' +import appLog from '@/i18n/locales/en-US/app-log.json' +import common from '@/i18n/locales/en-US/common.json' +import { createConsoleQueryWrapper } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' import { AgentLogsPage } from '../page' @@ -13,19 +18,8 @@ const mocks = vi.hoisted(() => ({ sources: vi.fn(), })) -vi.mock('react-i18next', async () => { - const { createReactI18nextMock } = await import('@/test/i18n-mock') - const { default: agentV2 } = await import('@/i18n/locales/en-US/agent-v-2.json') - const { default: appLog } = await import('@/i18n/locales/en-US/app-log.json') - const { default: common } = await import('@/i18n/locales/en-US/common.json') - return createReactI18nextMock({ ...agentV2, ...appLog, ...common }) -}) - -vi.mock('@/context/i18n', async (importOriginal) => ({ - ...(await importOriginal()), - useDocLink: () => (path: string) => path, -})) -vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: () => '' }) })) +vi.unmock('react-i18next') +// boundary: generated Console transport supplies the log-list server responses. vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const { withConsoleResponses } = await import('@/test/console/service-responses') @@ -52,18 +46,30 @@ it('keeps log filters and pagination reachable at a 320 by 256 viewport', async await page.viewport(320, 256) mocks.logs.mockResolvedValue(emptyLogs) mocks.sources.mockResolvedValue(emptySources) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const i18n = createInstance() + await i18n.init({ + lng: 'en-US', + resources: { 'en-US': { agentV2, appLog, common } }, + keySeparator: false, + interpolation: { escapeValue: false }, + }) + const { queryClient } = createConsoleQueryWrapper() try { const screen = await render( -
- -
+ +
+ +
+
, ) await expect.element(screen.getByText('No logs found')).toBeVisible() + await expect + .element(screen.getByRole('link', { name: 'Learn more' })) + .toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/monitor/logs') const section = screen.getByRole('region', { name: 'Logs' }).first().element() const panel = section.firstElementChild as HTMLElement const controls = [ diff --git a/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx b/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx index 34e5b5457a6..482dfc5e20c 100644 --- a/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx +++ b/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx @@ -1,9 +1,14 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient } from '@tanstack/react-query' +import { createInstance } from 'i18next' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { I18nextProvider } from 'react-i18next' import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' +import common from '@/i18n/locales/en-US/common.json' +import contacts from '@/i18n/locales/en-US/contacts.json' import { seedSystemFeatures } from '@/test/console/query-data' +import { QueryClientTestProvider } from '@/test/console/query-provider' import { ContactsImPlatformRuntimeProvider } from '../composition' import { ContactsImPlatformManagementSurface } from '../management-surface' import { channelSummary } from './fixtures/channel-client' @@ -12,6 +17,8 @@ import { channel, defaultResponse, json } from './fixtures/http' const network = vi.hoisted(() => ({ handle: vi.fn<(request: Request) => Promise | Response>(), })) +vi.unmock('react-i18next') +// boundary: the HTTP adapter is the server edge below the real contacts runtime. vi.mock('@/service/base', async () => { const { httpErrorFromResponse } = await import('@/service/http-error') return { @@ -22,10 +29,6 @@ vi.mock('@/service/base', async () => { }, } }) -vi.mock('@/utils/client', () => ({ isClient: true, isServer: false })) -vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, -})) // These surfaces previously lost their scrims when moved under Settings' transparent modal. // Independent component tests miss that nested-dialog condition. Real rendering must prove @@ -67,23 +70,32 @@ async function setup() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) + const i18n = createInstance() + await i18n.init({ + lng: 'en-US', + resources: { 'en-US': { common, contacts } }, + keySeparator: false, + interpolation: { escapeValue: false }, + }) seedSystemFeatures(queryClient, { deployment_edition: 'CLOUD' }) await render( - - - - - - - - - , + + + + + + + + + + + , ) await expect.element(page.getByRole('group', { name: 'Feishu', exact: true })).toBeVisible() expect(visibleViewportScrims()).toBe(0) @@ -97,40 +109,43 @@ it.each(['IM configuration', 'Email configuration', 'replacement', 'deletion', ' const client = await setup() try { let role: 'dialog' | 'alertdialog' = 'dialog' - let title = /contacts\.imPlatform\.bindingDialog\.configureTitle/ + let title = /^Configure /i if (surface === 'IM configuration' || surface === 'Email configuration') { const provider = surface === 'IM configuration' ? 'Feishu' : 'Email' await page .getByRole('group', { name: provider, exact: true }) - .getByRole('button', { name: /contacts\.imPlatform\.action\.configureChannel/ }) + .getByRole('button', { name: `Configure ${provider}` }) .click() - if (surface === 'Email configuration') title = /contacts\.imPlatform\.email\.title/ + title = new RegExp(`^Configure ${provider}$`) } else if (surface === 'replacement') { await page .getByRole('group', { name: 'Slack', exact: true }) - .getByRole('button', { name: /contacts\.imPlatform\.action\.replace/ }) + .getByRole('button', { name: 'Slack — Replace' }) .click() role = 'alertdialog' - title = /contacts\.imPlatform\.replacement\.title/ + title = /^Replace .+ with .+\?$/ } else if (surface === 'deletion') { await page .getByRole('group', { name: 'Feishu', exact: true }) - .getByRole('button', { name: /contacts\.imPlatform\.action\.deleteChannel/ }) + .getByRole('button', { name: 'Delete Feishu' }) .click() role = 'alertdialog' - title = /contacts\.imPlatform\.delete\.title/ + title = /^Delete Feishu channel\?$/ } else { - await page.getByRole('button', { name: 'contacts.imPlatform.action.viewDetails' }).click() - title = /contacts\.imPlatform\.details\.title/ + await page.getByRole('button', { name: 'View details' }).click() + title = /^Sync details$/ } const popup = page.getByRole(role, { name: title }) await expect.element(popup).toBeVisible() await expect.poll(visibleViewportScrims).toBe(1) - if (surface === 'IM configuration') { - await popup.getByRole('button', { name: 'contacts.imPlatform.bindingDialog.back' }).click() + if (surface === 'Email configuration') { await expect - .element(popup.getByLabelText('contacts.imPlatform.bindingDialog.field.appId')) - .toBeVisible() + .element(popup.getByRole('link', { name: 'Learn more' })) + .toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/nodes/human-input') + } + if (surface === 'IM configuration') { + await popup.getByRole('button', { name: 'Back' }).click() + await expect.element(popup.getByLabelText('App ID')).toBeVisible() expect(visibleViewportScrims()).toBe(1) } await userEvent.keyboard('{Escape}') diff --git a/web/features/network-access/policy-editor/__tests__/focus.browser.spec.tsx b/web/features/network-access/policy-editor/__tests__/focus.browser.spec.tsx index 13fb6651490..fe731e68bfc 100644 --- a/web/features/network-access/policy-editor/__tests__/focus.browser.spec.tsx +++ b/web/features/network-access/policy-editor/__tests__/focus.browser.spec.tsx @@ -1,7 +1,10 @@ import type { ComponentProps } from 'react' +import { createInstance } from 'i18next' +import { I18nextProvider } from 'react-i18next' import { userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { IpPolicyDialog } from '@/features/network-access/policy-editor' +import common from '@/i18n/locales/en-US/common.json' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { createDeferred } from '@/test/deferred' @@ -9,6 +12,7 @@ const requests = vi.hoisted(() => ({ currentIp: vi.fn<() => Promise<{ client_ip: string }>>(), })) +// boundary: the Browser Console transport is the current-IP server edge. vi.mock('@/service/console/browser', () => ({ consoleBrowserLink: { call: (path: readonly string[]) => { @@ -19,22 +23,26 @@ vi.mock('@/service/console/browser', () => ({ }, })) -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal() - const { createReactI18nextMock } = await import('@/test/i18n-mock') - const { default: translations } = await import('@/i18n/locales/en-US/common.json') - return { ...actual, ...createReactI18nextMock(translations) } -}) +vi.unmock('react-i18next') beforeEach(() => { requests.currentIp.mockReset() }) async function renderEditor(props: Partial> = {}) { + const i18n = createInstance() + await i18n.init({ + lng: 'en-US', + resources: { 'en-US': { common } }, + keySeparator: false, + interpolation: { escapeValue: false }, + }) const { queryClient, wrapper: ConsoleProvider } = createConsoleQueryWrapper() const screen = await render( - + + + , ) return { screen, queryClient } diff --git a/web/features/new-rag/create/__tests__/model-settings.browser.spec.tsx b/web/features/new-rag/create/__tests__/model-settings.browser.spec.tsx index 92e6fed244a..6afbb276b38 100644 --- a/web/features/new-rag/create/__tests__/model-settings.browser.spec.tsx +++ b/web/features/new-rag/create/__tests__/model-settings.browser.spec.tsx @@ -1,5 +1,12 @@ +import { createInstance } from 'i18next' +import { I18nextProvider } from 'react-i18next' import { page } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' +import common from '@/i18n/locales/en-US/common.json' +import knowledgeCreate from '@/i18n/locales/en-US/knowledge-create.json' +import knowledgeSettings from '@/i18n/locales/en-US/knowledge-settings.json' +import knowledgeSpace from '@/i18n/locales/en-US/knowledge-space.json' +import modelProvider from '@/i18n/locales/en-US/model-provider.json' import { consoleQuery } from '@/service/console' import { createCatalogModelFixture, @@ -11,41 +18,26 @@ import { createConsoleQueryWrapper } from '@/test/console/query-data' import { QueryTestingAdapter } from '@/test/query-state-testing-adapter' import { CreateKnowledgePage } from '../page' +// boundary: the Next router is unavailable in the isolated Browser test runtime. vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: vi.fn() }), useSearchParams: () => new URLSearchParams(), })) -// boundary: Browser setup does not load the product namespaces needed for translated model labels. -vi.mock('react-i18next', async () => { - const actual = await vi.importActual('react-i18next') - const { createReactI18nextMock } = await import('@/test/i18n-mock') - const { default: creation } = await import('@/i18n/locales/en-US/knowledge-create.json') - const { default: space } = await import('@/i18n/locales/en-US/knowledge-space.json') - const { default: settings } = await import('@/i18n/locales/en-US/knowledge-settings.json') - const { default: provider } = await import('@/i18n/locales/en-US/model-provider.json') - const { default: common } = await import('@/i18n/locales/en-US/common.json') - return { - ...actual, - ...createReactI18nextMock( - Object.fromEntries( - Object.entries({ - knowledgeCreate: creation, - knowledgeSpace: space, - knowledgeSettings: settings, - modelProvider: provider, - common, - }).flatMap(([namespace, translations]) => - Object.entries(translations).map(([key, value]) => [`${namespace}.${key}`, value]), - ), - ), - ), - } -}) +vi.unmock('react-i18next') it('keeps model menus usable inside the scrolling creation form on a small viewport', async () => { // Real layout and hit testing cover the scrolling form, fixed footer, and portalled menu. await page.viewport(640, 600) + const i18n = createInstance() + await i18n.init({ + lng: 'en-US', + resources: { + 'en-US': { common, knowledgeCreate, knowledgeSettings, knowledgeSpace, modelProvider }, + }, + keySeparator: false, + interpolation: { escapeValue: false }, + }) const { queryClient, wrapper: QueryProvider } = createConsoleQueryWrapper({ systemFeatures: { deployment_edition: 'ENTERPRISE', @@ -98,9 +90,11 @@ it('keeps model menus usable inside the scrolling creation form on a small viewp }) const screen = await render( - - - + + + + + , ) const settings = screen.getByRole('button', { name: 'Model settings', exact: true }) diff --git a/web/features/new-rag/sources/setup/__tests__/sync-policy-field.browser.spec.tsx b/web/features/new-rag/sources/setup/__tests__/sync-policy-field.browser.spec.tsx index ec773794e77..3d5d78b544a 100644 --- a/web/features/new-rag/sources/setup/__tests__/sync-policy-field.browser.spec.tsx +++ b/web/features/new-rag/sources/setup/__tests__/sync-policy-field.browser.spec.tsx @@ -1,47 +1,20 @@ +import type { ReactNode } from 'react' import type { SyncPolicyValue } from '../sync-policy-field' +import { createInstance } from 'i18next' import { useState } from 'react' +import { I18nextProvider } from 'react-i18next' import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' +import commonEn from '@/i18n/locales/en-US/common.json' +import knowledgeSourcesEn from '@/i18n/locales/en-US/knowledge-sources.json' +import knowledgeSpaceEn from '@/i18n/locales/en-US/knowledge-space.json' +import commonZh from '@/i18n/locales/zh-Hans/common.json' +import knowledgeSourcesZh from '@/i18n/locales/zh-Hans/knowledge-sources.json' +import knowledgeSpaceZh from '@/i18n/locales/zh-Hans/knowledge-space.json' import { guardUnexpectedFetch } from '@/test/unexpected-fetch' import { SyncPolicyField } from '../sync-policy-field' -const i18nLanguage = vi.hoisted(() => ({ current: 'en-US' })) - -// boundary: Browser setup cannot switch the i18n language while preserving these interval labels. -vi.mock('react-i18next', async () => { - const { createReactI18nextMock } = await import('@/test/i18n-mock') - const mock = createReactI18nextMock({ - 'common.operation.cancel': 'Cancel', - 'knowledgeSources.syncPolicy': 'Sync policy', - 'knowledgeSources.syncPolicyApply': 'Apply', - 'knowledgeSources.syncPolicyCustom': 'Custom interval', - 'knowledgeSources.syncPolicyCustomDescription': - 'First sync runs right after creation, then {{interval}}.', - 'knowledgeSources.syncPolicyCustomHelp': 'From 1 hour to 30 days. Will sync {{interval}}.', - 'knowledgeSources.syncPolicyCustomValue': 'Custom · {{interval}}', - 'knowledgeSources.syncPolicyDaily': 'Every 24 hours', - 'knowledgeSources.syncPolicyEditCustom': 'Edit custom interval', - 'knowledgeSources.syncPolicyEvery': 'Every', - 'knowledgeSources.syncPolicyEveryValue': 'Every {{interval}}', - 'knowledgeSources.syncPolicyManual': 'Manual sync', - 'knowledgeSpace.syncPolicyUnit.days': 'days', - 'knowledgeSpace.syncPolicyUnit.hours': 'hours', - }) - return { - ...mock, - useTranslation: ((...args: Parameters) => { - const result = mock.useTranslation(...args) - return { - ...result, - i18n: { - ...result.i18n, - language: i18nLanguage.current, - resolvedLanguage: i18nLanguage.current, - }, - } - }) as typeof mock.useTranslation, - } -}) +vi.unmock('react-i18next') function SyncPolicyFieldHarness() { const [value, setValue] = useState({ mode: 'interval' }) @@ -53,16 +26,38 @@ function SyncPolicyFieldHarness() { ) } +async function renderWithI18n(node: ReactNode, locale: 'en-US' | 'zh-Hans' = 'en-US') { + const i18n = createInstance() + await i18n.init({ + lng: locale, + fallbackLng: false, + resources: { + 'en-US': { + common: commonEn, + knowledgeSources: knowledgeSourcesEn, + knowledgeSpace: knowledgeSpaceEn, + }, + 'zh-Hans': { + common: commonZh, + knowledgeSources: knowledgeSourcesZh, + knowledgeSpace: knowledgeSpaceZh, + }, + }, + keySeparator: false, + interpolation: { escapeValue: false }, + }) + return render({node}) +} + describe('SyncPolicyField', () => { beforeEach(async () => { await page.viewport(640, 600) onTestFinished(guardUnexpectedFetch()) - i18nLanguage.current = 'en-US' }) it('shows sync timing for default and preset intervals but not manual sync', async () => { const user = userEvent - await render() + await renderWithI18n() const trigger = page.getByRole('combobox', { name: 'Sync policy' }) await expect.element(trigger).toHaveTextContent('Every 24 hours') @@ -77,31 +72,30 @@ describe('SyncPolicyField', () => { .toBeVisible() await user.click(trigger) - await user.click(page.getByRole('option', { name: 'Manual sync' })) - await expect.element(trigger).toHaveTextContent('Manual sync') + await user.click(page.getByRole('option', { name: 'Manual only' })) + await expect.element(trigger).toHaveTextContent('Manual only') await expect .element(page.getByText(/^First sync runs right after creation/)) .not.toBeInTheDocument() }) it('separates numbers from units in Simplified Chinese interval labels', async () => { - i18nLanguage.current = 'zh-Hans' const user = userEvent - await render() + await renderWithI18n(, 'zh-Hans') - await user.click(page.getByRole('combobox', { name: 'Sync policy' })) + await user.click(page.getByRole('combobox', { name: '同步策略' })) - await expect.element(page.getByRole('option', { name: 'Every 6 小时' })).toBeInTheDocument() - await expect.element(page.getByRole('option', { name: 'Every 3 天' })).toBeInTheDocument() + await expect.element(page.getByRole('option', { name: '每 6 小时' })).toBeInTheDocument() + await expect.element(page.getByRole('option', { name: '每 3 天' })).toBeInTheDocument() }) it('offers the designed presets and applies a custom day interval explicitly', async () => { const user = userEvent - await render() + await renderWithI18n() const trigger = page.getByRole('combobox', { name: 'Sync policy' }) await user.click(trigger) - await expect.element(page.getByRole('option', { name: 'Manual sync' })).toBeInTheDocument() + await expect.element(page.getByRole('option', { name: 'Manual only' })).toBeInTheDocument() await expect.element(page.getByRole('option', { name: 'Every 6 hours' })).toBeInTheDocument() await expect.element(page.getByRole('option', { name: 'Every 12 hours' })).toBeInTheDocument() await expect.element(page.getByRole('option', { name: 'Every 24 hours' })).toBeInTheDocument() @@ -163,7 +157,7 @@ describe('SyncPolicyField', () => { ) } - await render() + await renderWithI18n() await user.click(page.getByRole('combobox', { name: 'Sync policy' })) await user.click(page.getByRole('option', { name: 'Custom interval' })) @@ -184,7 +178,7 @@ describe('SyncPolicyField', () => { it('closes the custom interval popover on Escape and resets its draft when reopened', async () => { const user = userEvent - await render() + await renderWithI18n() const trigger = page.getByRole('combobox', { name: 'Sync policy' }) await user.click(trigger) @@ -205,7 +199,7 @@ describe('SyncPolicyField', () => { it('discards edits on cancel and clamps the value to 30 days', async () => { const user = userEvent - await render() + await renderWithI18n() const trigger = page.getByRole('combobox', { name: 'Sync policy' }) await user.click(trigger) diff --git a/web/scripts/check-browser-spec-policy.ts b/web/scripts/check-browser-spec-policy.ts index 942b0186c3b..17ec646bf6b 100644 --- a/web/scripts/check-browser-spec-policy.ts +++ b/web/scripts/check-browser-spec-policy.ts @@ -294,96 +294,12 @@ const MOCK_ALLOWLIST: MockAllowance[] = [ occurrence: 1, reason: legacyMockDebt, }, - { - path: 'features/agent-v2/__tests__/delete-agent.browser.spec.tsx', - specifier: 'react-i18next', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/__tests__/delete-agent.browser.spec.tsx', - specifier: '@/features/agent-v2/permissions', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/__tests__/delete-agent.browser.spec.tsx', - specifier: '@/context/i18n', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/__tests__/delete-agent.browser.spec.tsx', - specifier: '@/hooks/use-timestamp', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/__tests__/delete-agent.browser.spec.tsx', - specifier: '@/app/components/app/use-export-app-dsl', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.browser.spec.tsx', - specifier: '@/app/notifications', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.browser.spec.tsx', - specifier: '@/hooks/use-format-time-from-now', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.browser.spec.tsx', - specifier: '@/hooks/use-timestamp', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.browser.spec.tsx', - specifier: '../../version-restore', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/agent-detail/logs/__tests__/page.browser.spec.tsx', - specifier: 'react-i18next', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/agent-detail/logs/__tests__/page.browser.spec.tsx', - specifier: '@/context/i18n', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/agent-v2/agent-detail/logs/__tests__/page.browser.spec.tsx', - specifier: '@/hooks/use-timestamp', - occurrence: 1, - reason: legacyMockDebt, - }, - { - path: 'features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx', - specifier: '@/context/i18n', - occurrence: 1, - reason: legacyMockDebt, - }, { path: 'features/network-access/app-binding/__tests__/focus.browser.spec.tsx', specifier: '@/app/components/base/amplitude', occurrence: 1, reason: legacyMockDebt, }, - { - path: 'features/network-access/policy-editor/__tests__/focus.browser.spec.tsx', - specifier: 'react-i18next', - occurrence: 1, - reason: legacyMockDebt, - }, { path: 'features/new-rag/documents/__tests__/delete-document.browser.spec.tsx', specifier: '../../components/knowledge-model-readiness-banner',