diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index fed0c4c31d1c..839c4a73d542 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -93,9 +93,8 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: - native-runtime: node + native-runtime: none node-version: ${{ matrix.node }} - cache-electron-package: 'true' cache-dependency-path: | pnpm-lock.yaml cloud/pnpm-lock.yaml diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index c48770d9a885..4e7e83a85ba6 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -10,6 +10,7 @@ import { isTestOnlySourcePath } from './test-only-source-path.mjs' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate']) +const CLASS_PROPERTY_NAMES = new Set(['className', 'classNames']) const USER_VISIBLE_JSX_ATTRIBUTES = new Set([ 'ariaLabel', 'aria-label', @@ -314,7 +315,7 @@ function isUserVisibleCallArgument(node) { } function classifyStringNode(node) { - if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) { + if (hasAncestorObjectPropertyName(node, CLASS_PROPERTY_NAMES)) { return undefined } @@ -424,10 +425,16 @@ export function collectLocalizationCandidates(filePath, sourceText, root = proce return } - const kind = classifyStringNode(node) - if (kind) { - for (const part of stringParts(node)) { - pushReport(node, kind, part.text, part.dynamic) + if ( + ts.isStringLiteralLike(node) || + ts.isNoSubstitutionTemplateLiteral(node) || + ts.isTemplateExpression(node) + ) { + const kind = classifyStringNode(node) + if (kind) { + for (const part of stringParts(node)) { + pushReport(node, kind, part.text, part.dynamic) + } } } diff --git a/config/scripts/audit-localization-coverage.test.mjs b/config/scripts/audit-localization-coverage.test.mjs index 6460dc9f36d3..1615bc10556c 100644 --- a/config/scripts/audit-localization-coverage.test.mjs +++ b/config/scripts/audit-localization-coverage.test.mjs @@ -41,6 +41,52 @@ describe('localization coverage candidates', () => { expect(reports).toEqual([]) }) + + it('sees static templates and every dynamic template part in visible calls', () => { + const reports = candidates( + 'Sample.ts', + 'confirm(`Retry the sync`); alert(`Reconnect ${host} after ${attempts} attempts`);' + ) + + expect(reports.map(({ text, dynamic }) => ({ text, dynamic }))).toEqual([ + { text: 'Retry the sync', dynamic: false }, + { text: 'Reconnect', dynamic: true }, + { text: 'after', dynamic: true }, + { text: 'attempts', dynamic: true } + ]) + }) + + it('ignores nested class properties and translations while seeing sibling copy', () => { + const reports = candidates( + 'Sample.tsx', + [ + 'const config = {', + ' className: { label: "Hidden class copy" },', + ' classNames: ["Hidden token", { title: "Hidden nested token" }],', + ' title: `Connect ${host}`,', + ' message: "Reconnect the host",', + ' description: t("connection.ready", "Ready now")', + '};', + 'export const view = {t("connection.banner", { label: "Connection banner" })};' + ].join('\n') + ) + + expect(reports.map(({ text, dynamic }) => ({ text, dynamic }))).toEqual([ + { text: 'Connect', dynamic: true }, + { text: 'Reconnect the host', dynamic: false } + ]) + }) + + it('keeps traversing calls and callbacks to find nested JSX attributes and text', () => { + const reports = candidates( + 'Sample.tsx', + `export function Sample() { + return
{select(true, () => )}
+ }` + ) + + expect(reports.map((report) => report.text)).toEqual(['Retry after reconnect', 'Reconnect now']) + }) }) describe('localization coverage file skipping', () => { diff --git a/config/scripts/localization-extraction-change-scope.mjs b/config/scripts/localization-extraction-change-scope.mjs index 21fccd98d9bd..66667ef710bd 100644 --- a/config/scripts/localization-extraction-change-scope.mjs +++ b/config/scripts/localization-extraction-change-scope.mjs @@ -11,12 +11,25 @@ const TOOLCHAIN_FILES = new Set([ '.github/workflows/pr.yml' ]) +function isIgnoredTestSource(path) { + if (!/\.(?:js|jsx|ts|tsx|mts|cts)$/.test(path)) { + return false + } + const segments = path.split('/') + const filename = segments.at(-1) ?? '' + return ( + filename.includes('.test.') || + filename.includes('.spec.') || + segments.slice(0, -1).some((segment) => segment === '__tests__' || segment === '__snapshots__') + ) +} + export function affectsLocalizationExtraction(paths) { return paths.some( (path) => TOOLCHAIN_FILES.has(path) || - // Include all source paths so catalog moves and future extractor inputs stay covered. - path.startsWith('src/') || + // Keep catalogs and future source inputs; skip only configured test exclusions. + (path.startsWith('src/') && !isIgnoredTestSource(path)) || path.startsWith('config/i18next.') || path.startsWith('config/tsconfig') || path.startsWith('config/patches/') || diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index b1c142b8cd0b..f8a19e61a8bb 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -1,8 +1,20 @@ import { readFileSync } from 'node:fs' import { expect, it } from 'vitest' import { parse } from 'yaml' +import localizationConfig from '../i18next.config' import { affectsLocalizationExtraction } from './localization-extraction-change-scope.mjs' +it('pins the extractor inputs and exclusions used by the routing decision', () => { + expect(localizationConfig.extract.input).toEqual(['src/**/*.{js,jsx,ts,tsx,mts,cts}']) + expect(localizationConfig.extract.ignore).toEqual([ + '**/*.test.*', + '**/*.spec.*', + '**/__tests__/**', + '**/__snapshots__/**', + '**/assets/**' + ]) +}) + it.each([ 'src/renderer/src/components/Example.tsx', 'src/main/notifications.ts', @@ -24,6 +36,52 @@ it.each([ expect(affectsLocalizationExtraction([path])).toBe(true) }) +it.each([ + 'src/main/notifications.test.ts', + 'src/main/notifications.spec.ts', + 'src/renderer/src/components/Example.test.tsx', + 'src/main/example.test.js', + 'src/main/example.spec.jsx', + 'src/main/example.test.mts', + 'src/main/example.spec.cts', + 'src/.test.ts', + 'src/.spec.ts', + 'src/__tests__/notifications.ts', + 'src/main/__tests__/nested/notifications.ts', + 'src/main/__snapshots__/notifications.ts' +])('avoids extraction for an explicitly ignored test source: %s', (path) => { + expect(affectsLocalizationExtraction([path])).toBe(false) +}) + +it.each([ + 'src/main/notifications-test.ts', + 'src/main/notifications.test-support.ts', + 'src/main/notifications.specification.ts', + 'src/main/notifications.TEST.ts', + 'src/main/.test./notifications.ts', + 'src/main/notifications.test/notifications.ts', + 'src/main/__tests__-support/notifications.ts', + 'src/main/__snapshots__-support/notifications.ts', + 'src/main/__tests__.ts', + 'src/main/assets/notifications.ts', + 'src/main/__tests__/en.json', + 'src/renderer/src/i18n/locales/en.test.json', + 'src/main/notifications.test.unknown' +])('retains extraction for catalogs, assets and near-match source paths: %s', (path) => { + expect(affectsLocalizationExtraction([path])).toBe(true) +}) + +it('retains mixed changes and both sides of source-to-test renames', () => { + const ignored = 'src/main/notifications.test.ts' + expect(affectsLocalizationExtraction([ignored, 'src/main/notifications.ts'])).toBe(true) + expect(affectsLocalizationExtraction(['src/main/notifications.ts', ignored])).toBe(true) + expect(affectsLocalizationExtraction([ignored, 'config/i18next.config.ts'])).toBe(true) + expect(affectsLocalizationExtraction([ignored, 'src/renderer/src/i18n/locales/en.json'])).toBe( + true + ) + expect(affectsLocalizationExtraction([ignored, 'src/main/notifications.spec.ts'])).toBe(false) +}) + it('avoids extraction for unrelated CI, documentation, and native changes', () => { expect( affectsLocalizationExtraction([ diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 611ef54b8cb6..aa8146649bb9 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -2583,3 +2583,47 @@ The single hosted trial occupied 134 runner-seconds including all six samples, shared setup and upload. That is trial cost, not a production saving. These focused process measurements do not establish full-shard savings, PR runtime percentiles, queue relief or a change in the organization's runner allowance. + +## October 5 localization gate work + +The localization coverage audit now classifies only AST nodes that can emit string +parts, and reuses the class-property exclusion set. It still visits every child and +handles JSX text separately. The extraction scope skips only source extensions and +test paths explicitly excluded by the real extractor configuration. Catalogs, +assets, unknown extensions, configuration changes and mixed production changes +still select extraction; both sides of a rename remain covered. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37360932616) +used fixed source `761d63a4e52ff7915d4432c004df13182db63010`, Linux ARM, +Node 24.21.0 and pnpm 12.8.1. Dependency setup was shared outside the samples. +Each timed check was a fresh process with Node compile caching disabled. + +| Complete coverage audit | Original | Candidate | Paired saving | +| ----------------------- | -------- | --------- | ------------- | +| Pair 1 | 9.182s | 4.924s | 4.258s | +| Pair 2, reversed order | 9.133s | 4.924s | 4.209s | +| Pair 3 | 8.930s | 4.924s | 4.007s | + +The median paired saving is 4.209 seconds, or +46.1% of this audit. All three pairs improved; the original range was +0.252 seconds. Complete JSON finding inventories match byte-for-byte, +including all 13 findings and their source locations. All 53 candidate tests pass. +Four real audit mutations fail their intended assertions. An ignored test fixture +passes the real extraction gate; renaming it into production source selects the +gate and fails on the deliberately missing translation key. + +For an existing ignored test path, three conditional comparisons avoid a median +27.628 seconds of extraction work. Original arms really run the full gate +and pass; candidate extraction arms are explicitly `not_selected`. The routing +tests pin the actual extractor inputs and exclusions, so configuration drift fails +the tests. This saving applies only when every changed source path is ignored. + +Relay integration also omits desktop native preparation and the Electron archive +cache. Its existing two files use Node's SQLite, HTTP and WebSocket paths; general +unit shards retain their desktop setup. The existing 16 relay cases pass locally +on Node 24.20.0 and 26.7.0. Removed setup work is not a measured timing saving. + +These comparisons exclude queues and other checks. Extraction already overlaps +other preflight work, so conditional avoidance does not translate directly into +preflight wall time. No change to full-PR percentiles or the concurrency allowance +is established.