diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.plugin-locale.test.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.plugin-locale.test.tsx new file mode 100644 index 000000000000..3e3b092a1391 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectRoadmap.plugin-locale.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ProjectRoadmap from './ProjectRoadmap' +import { i18n, setRendererPluginLanguagePacks } from '@/i18n/i18n' +import { DEFAULT_LOCALE } from '@/i18n/supported-languages' +import { pluginLanguageResourceId } from '../../../../shared/plugins/plugin-language-pack-artifact' +import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github/project-types' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
+})) + +// The exact pack from the field crash report (v1.4.219, Windows). +const PACK_ID = 'plugin:stablyai.orca-portuguese/pt-BR' as const +const PACK_RESOURCE = pluginLanguageResourceId(PACK_ID) + +function roadmapTable(): GitHubProjectTable { + const row: GitHubProjectRow = { + id: 'one', + itemType: 'ISSUE', + content: { + number: 7, + title: 'Scheduled', + body: null, + url: 'https://github.com/o/r/issues/7', + state: 'OPEN', + stateReason: null, + isDraft: null, + repository: 'o/r', + assignees: [], + labels: [], + parentIssue: null, + issueType: null + }, + fieldValuesByFieldId: { + f_start: { kind: 'date', fieldId: 'f_start', date: '2026-09-01' }, + f_end: { kind: 'date', fieldId: 'f_end', date: '2026-10-15' } + }, + updatedAt: '2026-08-31T00:00:00Z', + position: 0 + } + return { + project: { + id: 'PVT_1', + owner: 'stablyai', + ownerType: 'organization', + number: 3, + title: 'Orca', + url: 'https://github.com/orgs/stablyai/projects/3' + }, + selectedView: { + id: 'PVTV_1', + number: 2, + name: 'Roadmap', + layout: 'ROADMAP_LAYOUT', + filter: '', + fields: [ + { kind: 'field', id: 'f_title', name: 'Title', dataType: 'TITLE' }, + { kind: 'field', id: 'f_start', name: 'Start date', dataType: 'DATE' }, + { kind: 'field', id: 'f_end', name: 'Target date', dataType: 'DATE' } + ], + groupByFields: [], + sortByFields: [] + }, + rows: [row], + totalCount: 1, + parentFieldDropped: false + } +} + +afterEach(async () => { + cleanup() + window.localStorage.clear() + setRendererPluginLanguagePacks([]) + await i18n.changeLanguage(DEFAULT_LOCALE) +}) + +describe('ProjectRoadmap with a plugin language pack active', () => { + it('formats ticks with the pack locale instead of the synthetic resource tag', async () => { + setRendererPluginLanguagePacks([ + { + id: PACK_ID, + resourceLanguage: PACK_RESOURCE, + pluginKey: 'stablyai.orca-portuguese', + locale: 'pt-BR', + // Non-empty so i18next resolves to the pack, as a shipped pack does. + catalog: { common: { cancel: 'Cancelar' } } + } + ]) + await i18n.changeLanguage(PACK_RESOURCE) + expect(i18n.resolvedLanguage).toBe(PACK_RESOURCE) + + render(list} />) + + const expectedMonth = new Intl.DateTimeFormat('pt-BR', { + month: 'short', + timeZone: 'UTC' + }).format(new Date(Date.UTC(2026, 8, 1))) + expect(screen.getAllByText(expectedMonth, { exact: false }).length).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.tsx index 5e06e41d8700..26c0c40824ee 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.tsx @@ -3,7 +3,7 @@ import { CalendarClock } from 'lucide-react' import { Button } from '@/components/ui/button' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import { cn } from '@/lib/utils' -import { i18n, translate } from '@/i18n/i18n' +import { getIntlLocale, translate } from '@/i18n/i18n' import ProjectGroupHeader from './ProjectGroupHeader' import ProjectRoadmapBar from './ProjectRoadmapBar' import { ProjectTitleCell } from './ProjectCellIdentity' @@ -48,7 +48,7 @@ export default function ProjectRoadmap({ }: Props): React.JSX.Element { const view = table.selectedView const prefersReducedMotion = usePrefersReducedMotion() - const locale = i18n.resolvedLanguage ?? i18n.language + const locale = getIntlLocale() // Why: the grid lives on UTC calendar days (parseRoadmapDate), so "today" // must be the viewer's LOCAL calendar date mapped to UTC midnight — the raw // instant would shift the marker into the wrong day off UTC. diff --git a/src/renderer/src/i18n/no-raw-language-intl-locale.test.ts b/src/renderer/src/i18n/no-raw-language-intl-locale.test.ts new file mode 100644 index 000000000000..563d9a05b2ae --- /dev/null +++ b/src/renderer/src/i18n/no-raw-language-intl-locale.test.ts @@ -0,0 +1,91 @@ +/** + * Plugin language packs make `i18n.language` a synthetic `plugin` tag that + * every `Intl` constructor rejects with a RangeError, so Intl locales must come + * from `getIntlLocale()`. This catches the two shapes that shipped that crash: + * the raw language passed straight to Intl, or held in a `locale` variable. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { relative, resolve } from 'node:path' +// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const RENDERER_ROOT = resolve('src/renderer/src') +const LOCALE_METHODS = new Set([ + 'toLocaleString', + 'toLocaleDateString', + 'toLocaleTimeString', + 'localeCompare' +]) +const RAW_LANGUAGE = /\b(?:i18n|i18next)\??\.(?:language|resolvedLanguage)\b/ + +function collectSourceFiles(dir: string, files: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const filePath = resolve(dir, name) + if (statSync(filePath).isDirectory()) { + collectSourceFiles(filePath, files) + } else if ( + /\.(ts|tsx)$/.test(name) && + !/\.test\.(ts|tsx)$/.test(name) && + !filePath.includes('/i18n/locales/') + ) { + files.push(filePath) + } + } + return files +} + +function isLocaleSink(node: ts.CallExpression | ts.NewExpression, file: ts.SourceFile): boolean { + const callee = node.expression + if (callee.getText(file).startsWith('Intl.')) { + return true + } + return ts.isPropertyAccessExpression(callee) && LOCALE_METHODS.has(callee.name.text) +} + +describe('Intl locale source', () => { + it('never feeds the raw i18n language to Intl', () => { + const violations: string[] = [] + + for (const filePath of collectSourceFiles(RENDERER_ROOT)) { + const source = readFileSync(filePath, 'utf8') + if (!RAW_LANGUAGE.test(source)) { + continue + } + const file = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) + const report = (node: ts.Node): void => { + const { line } = file.getLineAndCharacterOfPosition(node.getStart(file)) + violations.push(`${relative(process.cwd(), filePath)}:${line + 1}`) + } + + function visit(node: ts.Node): void { + if ( + (ts.isCallExpression(node) || ts.isNewExpression(node)) && + isLocaleSink(node, file) && + node.arguments?.some((arg) => RAW_LANGUAGE.test(arg.getText(file))) + ) { + report(node) + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + /^locale$/i.test(node.name.text) && + node.initializer && + RAW_LANGUAGE.test(node.initializer.getText(file)) + ) { + report(node) + } + ts.forEachChild(node, visit) + } + + visit(file) + } + + expect(violations).toEqual([]) + }, 15_000) +})