Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 }) => <div role="tooltip">{children}</div>
}))

// 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(<ProjectRoadmap table={roadmapTable()} fallback={<div>list</div>} />)

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)
})
})
4 changes: 2 additions & 2 deletions src/renderer/src/components/github-project/ProjectRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
91 changes: 91 additions & 0 deletions src/renderer/src/i18n/no-raw-language-intl-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Plugin language packs make `i18n.language` a synthetic `plugin<hex>` 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)
})
Loading