mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
603 lines
18 KiB
JavaScript
603 lines
18 KiB
JavaScript
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
collectLocalizationCandidates,
|
|
main as auditLocalizationCoverage
|
|
} from './audit-localization-coverage.mjs'
|
|
|
|
describe('mobile-localization-candidate-rules', () => {
|
|
it('finds grammatical fragments returned by repositoryCount', () => {
|
|
const source = `
|
|
function repositoryCount(count) {
|
|
return \`\${count} \${count === 1 ? 'repository' : 'repositories'}\`
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates('/repo/mobile/app/tasks.tsx', source, '/repo')
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['repository', 'repositories'])
|
|
})
|
|
|
|
it('finds copy embedded in mobile WebView documents', () => {
|
|
const source = String.raw`
|
|
export const HTML = \`<!doctype html>
|
|
<main data-placeholder="Start writing..."></main>
|
|
<button>Copy</button><button>Select All</button>
|
|
<script>
|
|
window.prompt('Link URL');
|
|
document.execCommand('insertHTML', false, '<p>Task ' + localized + '</p><p>Follow up</p>');
|
|
</script>
|
|
\`
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/example-html.ts',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Start writing...',
|
|
'Copy',
|
|
'Select All',
|
|
'Link URL',
|
|
'Task',
|
|
'Follow up'
|
|
])
|
|
})
|
|
|
|
it('finds static copy around dynamic WebView values and prompts', () => {
|
|
const source = [
|
|
'export const HTML = `<!doctype html>',
|
|
'<button title="Copy ${name}">Retry ${name}</button>',
|
|
'<script>window.alert(\\`Could not load ${name}\\`);</script>',
|
|
'`'
|
|
].join('\n')
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/example-webview-html.ts',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map(({ dynamic, text }) => ({ dynamic, text }))).toEqual([
|
|
{ dynamic: true, text: 'Copy' },
|
|
{ dynamic: true, text: 'Retry' },
|
|
{ dynamic: true, text: 'Could not load' }
|
|
])
|
|
})
|
|
|
|
it('finds arbitrary doctype documents and template-literal insertHTML copy', () => {
|
|
const source = [
|
|
'export const HTML = `<!doctype html>',
|
|
'<main>Diagram controls</main>',
|
|
'<script>',
|
|
"document.execCommand('insertHTML', false, \\`<p>Retry ${name}</p><p>Follow up</p>\\`);",
|
|
'</script>',
|
|
'`'
|
|
].join('\n')
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/pr-sidebar/MermaidDiagram.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map(({ dynamic, text }) => ({ dynamic, text }))).toEqual([
|
|
{ dynamic: false, text: 'Diagram controls' },
|
|
{ dynamic: true, text: 'Retry' },
|
|
{ dynamic: false, text: 'Follow up' }
|
|
])
|
|
})
|
|
|
|
it('finds literals assigned to variables that later render in JSX', () => {
|
|
const source = `
|
|
export function Example({ alternate }) {
|
|
const copy = alternate ? 'Primary explanation' : 'Alternate explanation'
|
|
let followUp = 'Initial recovery note'
|
|
const fileLocation = \`\${path}:L\${line}\`
|
|
if (alternate) {
|
|
followUp = 'Updated recovery note'
|
|
}
|
|
return <><Text>{copy}</Text><Text>{followUp}</Text><Text>{fileLocation}</Text></>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Primary explanation',
|
|
'Alternate explanation',
|
|
'Initial recovery note',
|
|
'Updated recovery note'
|
|
])
|
|
})
|
|
|
|
it('uses only assignments that can reach a rendered variable', () => {
|
|
const source = `
|
|
export function Example() {
|
|
let label = 'Dead initial value'
|
|
label = 'Actually rendered value'
|
|
return <Text>{label}</Text>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Actually rendered value'])
|
|
})
|
|
|
|
it('finds direct and assigned literals in user-visible JSX attributes', () => {
|
|
const source = `
|
|
export function Example() {
|
|
const copy = 'Save changes'
|
|
const className = 'not-visible-copy'
|
|
return <><Button title={copy} className={className} /><Button title={'Direct save'!} /><Button title={('Open menu' as string)} /><Button title={('Choose host' satisfies string)} /></>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Save changes',
|
|
'Direct save',
|
|
'Open menu',
|
|
'Choose host'
|
|
])
|
|
})
|
|
|
|
it('finds unlocalized success-toast object copy', () => {
|
|
const source = `
|
|
export const launch = {
|
|
options: { successToast: 'Quick command inserted' }
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/terminal/quick-commands.ts',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Quick command inserted'])
|
|
})
|
|
|
|
it('finds unlocalized fallbacks returned by commentAuthor', () => {
|
|
const source = `
|
|
function commentAuthor(comment) {
|
|
return comment.author ?? 'unknown'
|
|
}
|
|
export function Example({ comment }) {
|
|
return <Text>{commentAuthor(comment)}</Text>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/app/h/[hostId]/tasks.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['unknown'])
|
|
})
|
|
|
|
it('finds copy returned by a helper whose result is rendered', () => {
|
|
const source = `
|
|
function lookup() {
|
|
return 'Extracting model'
|
|
}
|
|
function internalValue() {
|
|
return 'internal-only'
|
|
}
|
|
function projectRowType() {
|
|
return 'issue'
|
|
}
|
|
export function Example() {
|
|
const render = lookup
|
|
return <>{projectRowType() ? <Text>{render()}</Text> : null}</>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Extracting model'])
|
|
})
|
|
|
|
it('finds copy returned through a rendered helper object', () => {
|
|
const source = `
|
|
function lookup() {
|
|
return 'Extracting object model'
|
|
}
|
|
export function Example() {
|
|
const renderers = { lookup }
|
|
return <Text>{renderers.lookup()}</Text>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Extracting object model'])
|
|
})
|
|
|
|
it('finds member, assigned, destructured, and inline rendered helpers', () => {
|
|
const source = `
|
|
function lookup() {
|
|
return 'Member alias raw copy'
|
|
}
|
|
const renderers = { lookup, inline: () => 'Inline member raw copy' }
|
|
const render = renderers.lookup
|
|
let assigned
|
|
assigned = renderers.lookup
|
|
const { inline } = renderers
|
|
export function Example() {
|
|
return <><Text>{render()}</Text><Text>{assigned()}</Text><Text>{inline()}</Text></>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Member alias raw copy',
|
|
'Inline member raw copy'
|
|
])
|
|
})
|
|
|
|
it('uses the last effective rendered-helper property', () => {
|
|
const source = `
|
|
function lookup() {
|
|
return 'Dead helper raw copy'
|
|
}
|
|
const base = { lookup }
|
|
const renderers = { ...base, lookup: () => 'Visible replacement raw copy' }
|
|
export function Example() {
|
|
return <Text>{renderers.lookup()}</Text>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Visible replacement raw copy'])
|
|
})
|
|
|
|
it('matches rendered variables by binding instead of identifier text', () => {
|
|
const source = `
|
|
const value = 'internal mode name'
|
|
export function Example() {
|
|
const value = 'Visible nested value'
|
|
return <Text>{value}</Text>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Visible nested value'])
|
|
})
|
|
|
|
it('finds Android notification-channel names', () => {
|
|
const source = `
|
|
const channel = {
|
|
name: 'Desktop Notifications',
|
|
importance: Notifications.AndroidImportance.HIGH
|
|
}
|
|
Notifications.setNotificationChannelAsync('orca-desktop', channel)
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/notifications/local-notification-scheduling.ts',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Desktop Notifications'])
|
|
})
|
|
|
|
it('finds bound and spread Android notification-channel names', () => {
|
|
const source = `
|
|
const channelName = 'Desktop Notifications'
|
|
const channel = { name: channelName }
|
|
const baseChannel = { name: 'Background Notifications' }
|
|
const spreadChannel = { ...baseChannel }
|
|
Notifications.setNotificationChannelAsync('orca-desktop', channel)
|
|
Notifications.setNotificationChannelAsync('orca-background', spreadChannel)
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/notifications/local-notification-scheduling.ts',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Desktop Notifications',
|
|
'Background Notifications'
|
|
])
|
|
})
|
|
|
|
it('uses assigned and last-written notification names without descending calls', () => {
|
|
const source = `
|
|
let assignedName
|
|
assignedName = 'Assigned channel name'
|
|
const base = { name: 'Dead fallback name' }
|
|
const assigned = { name: assignedName }
|
|
const overridden = { ...base, name: 'Actual desktop name' }
|
|
const computed = { name: makeName('Not itself the channel name') }
|
|
const assignedAfterCreation = {}
|
|
assignedAfterCreation.name = 'Assigned after creation'
|
|
Notifications.setNotificationChannelAsync('assigned', assigned)
|
|
Notifications.setNotificationChannelAsync('overridden', overridden)
|
|
Notifications.setNotificationChannelAsync('computed', computed)
|
|
Notifications.setNotificationChannelAsync('after-creation', assignedAfterCreation)
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/notifications/local-notification-scheduling.ts',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Assigned channel name',
|
|
'Actual desktop name',
|
|
'Assigned after creation'
|
|
])
|
|
})
|
|
|
|
it('keeps OS-visible channel names when a later assignment reaches JSX', () => {
|
|
const source = `
|
|
let channelName = 'OS-visible channel name'
|
|
const channel = { name: channelName }
|
|
Notifications.setNotificationChannelAsync('desktop', channel)
|
|
channelName = 'Later rendered value'
|
|
export const label = <Text>{channelName}</Text>
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/notifications/local-notification-scheduling.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'OS-visible channel name',
|
|
'Later rendered value'
|
|
])
|
|
})
|
|
|
|
it('reports raw copy from a namespace-local translator', () => {
|
|
const source = `
|
|
import { t } from '@/i18n/mobile-i18n'
|
|
namespace Local {
|
|
var t = (value) => value
|
|
export const label = <Text>{t('Namespace raw copy')}</Text>
|
|
}
|
|
export const translated = t('example.translated')
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Namespace raw copy'])
|
|
})
|
|
|
|
it('reports raw copy from a class-static-block translator shadow', () => {
|
|
const source = `
|
|
import { t } from '@/i18n/mobile-i18n'
|
|
class Example {
|
|
static {
|
|
var t = (value) => value
|
|
const label = <Text>{t('Static block raw copy')}</Text>
|
|
}
|
|
}
|
|
export const translated = t('example.translated')
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Static block raw copy'])
|
|
})
|
|
|
|
it('reports a rendered call after a translator alias is replaced', () => {
|
|
const source = `
|
|
import { t } from '@/i18n/mobile-i18n'
|
|
let tr = t
|
|
tr = (value) => value
|
|
export const label = <Text>{tr('Visible raw copy')}</Text>
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Visible raw copy'])
|
|
})
|
|
|
|
it('reports rendered member and logical calls after translator replacement', () => {
|
|
const source = `
|
|
import { t } from '@/i18n/mobile-i18n'
|
|
const box = { tr: t }
|
|
box.tr = (value) => value
|
|
let logical = t
|
|
logical &&= (value) => value
|
|
let closure = t
|
|
const closureBox = { tr: t }
|
|
function Later() { return <><Text>{closure('Scalar closure raw')}</Text><Text>{closureBox.tr('Member closure raw')}</Text></> }
|
|
closure = (value) => value
|
|
closureBox.tr = (value) => value
|
|
const a = { tr: t }
|
|
const b = { tr: (value) => value }
|
|
let y = a
|
|
y &&= b
|
|
a.tr = y.tr
|
|
export const label = <><Text>{box.tr('Member raw copy')}</Text><Text>{logical('Logical raw copy')}</Text><Text>{a.tr('Ambiguous property raw')}</Text><Later /></>
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Scalar closure raw',
|
|
'Member closure raw',
|
|
'Member raw copy',
|
|
'Logical raw copy',
|
|
'Ambiguous property raw'
|
|
])
|
|
})
|
|
|
|
it('reports non-null renders and raw calls after snapshot or member mutations', () => {
|
|
const source = `
|
|
import { t } from '@/i18n/mobile-i18n'
|
|
const raw = (value) => value
|
|
let source = raw
|
|
const snapshot = source
|
|
source = t
|
|
const box = { tr: t }
|
|
const alias = box
|
|
alias.tr = raw
|
|
const holder = { inner: { tr: t } }
|
|
holder.inner.tr = raw
|
|
let objectSource = { tr: raw }
|
|
const objectSnapshot = objectSource
|
|
objectSource = { tr: t }
|
|
const visible = 'Visible non-null copy'
|
|
function render() { return 'Helper non-null copy' }
|
|
export const label = <><Text>{snapshot('Raw snapshot copy')}</Text><Text>{box.tr('Raw alias mutation')}</Text><Text>{holder.inner.tr('Raw nested mutation')}</Text><Text>{objectSnapshot.tr('Raw object snapshot')}</Text><Text>{visible!}</Text><Text>{'Direct non-null copy'!}</Text><Text>{render!()}</Text></>
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Visible non-null copy',
|
|
'Helper non-null copy',
|
|
'Raw snapshot copy',
|
|
'Raw alias mutation',
|
|
'Raw nested mutation',
|
|
'Raw object snapshot',
|
|
'Direct non-null copy'
|
|
])
|
|
})
|
|
|
|
it('finds user-visible subject fallbacks in returned rows', () => {
|
|
const source = `
|
|
export function toRow(item) {
|
|
return { subject: item.subject || '(no commit message)' }
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/source-control/mobile-git-history.ts',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['(no commit message)'])
|
|
})
|
|
|
|
it('recognizes aliased translators and reports calls shadowed by parameters', () => {
|
|
const source = `
|
|
import { t as translateMobile } from '@/i18n/mobile-i18n'
|
|
import * as i18n from '@/i18n/mobile-i18n'
|
|
const localized = translateMobile('example.localized')
|
|
export function Example(translateMobile) {
|
|
return <Text>{translateMobile('Unlocalized shadowed copy')}</Text>
|
|
}
|
|
export function MemberExample(i18n) {
|
|
return <Text>{i18n.t('Unlocalized shadowed member copy')}</Text>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual([
|
|
'Unlocalized shadowed copy',
|
|
'Unlocalized shadowed member copy'
|
|
])
|
|
})
|
|
|
|
it('reports calls shadowing a prefixed translator alias', () => {
|
|
const source = `
|
|
import { createMobileTranslator } from '@/i18n/mobile-i18n'
|
|
const translateExample = createMobileTranslator('example')
|
|
export function Example(translateExample) {
|
|
return <Text>{translateExample('Shadowed prefixed raw copy')}</Text>
|
|
}
|
|
`
|
|
const candidates = collectLocalizationCandidates(
|
|
'/repo/mobile/src/components/Example.tsx',
|
|
source,
|
|
'/repo'
|
|
)
|
|
|
|
expect(candidates.map((candidate) => candidate.text)).toEqual(['Shadowed prefixed raw copy'])
|
|
})
|
|
|
|
it('rejects stale allowlist entries before their approval can be reused', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-allowlist-'))
|
|
const sourceDirectory = path.join(root, 'mobile', 'src')
|
|
const configDirectory = path.join(root, 'config')
|
|
mkdirSync(sourceDirectory, { recursive: true })
|
|
mkdirSync(configDirectory, { recursive: true })
|
|
writeFileSync(path.join(sourceDirectory, 'Example.tsx'), "export const value = 'internal'\n")
|
|
writeFileSync(
|
|
path.join(configDirectory, 'allowlist.json'),
|
|
`${JSON.stringify([
|
|
{
|
|
filePath: 'mobile/src/Example.tsx',
|
|
kind: 'jsx-text',
|
|
text: 'Removed approved copy',
|
|
dynamic: false,
|
|
count: 1
|
|
}
|
|
])}\n`
|
|
)
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
|
try {
|
|
await expect(
|
|
auditLocalizationCoverage(root, [
|
|
'--check',
|
|
'--source-root',
|
|
'mobile/src',
|
|
'--allowlist',
|
|
'config/allowlist.json'
|
|
])
|
|
).resolves.toBe(1)
|
|
expect(error.mock.calls.flat().join('\n')).toContain(
|
|
'Stale localization allowlist entries were found.'
|
|
)
|
|
} finally {
|
|
error.mockRestore()
|
|
}
|
|
})
|
|
})
|