From 3484b05ee84bf3df2da4607f1a7f2c36626ec9a4 Mon Sep 17 00:00:00 2001 From: Arvid Andersson Date: Thu, 8 Oct 2026 09:53:51 +0200 Subject: [PATCH 1/2] Stand down when a sync belongs to another app in the repo - Send the config's projectId when fetching a sync; the server answers with project_matches (localheroai/localhero-ai#841) - On a mismatch, print a notice and return before validating config, writing files, saving config, committing or completing the update - Exit 0: a job that is not the target has nothing to do - No project_matches from an older server: behave as before - Real-filesystem test with two app folders, each with its own localhero.json --- CHANGELOG.md | 3 + src/api/sync.ts | 12 ++- src/commands/ci.ts | 19 ++++- tests/commands/ci-monorepo.test.ts | 116 +++++++++++++++++++++++++++++ tests/commands/ci.test.ts | 47 ++++++++++++ 5 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 tests/commands/ci-monorepo.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d924c..446136f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed +- In a monorepo with one Localhero project per app, a sync from the web app is now applied only by the app it belongs to. Every app's job receives the same sync, and before, the others tried to write its files into their own folder and failed the run, which could cancel the job that was meant to apply it. They now print a notice and exit 0 without touching any file. Needs the matching server change; against an older server the CLI behaves as before. + ## [0.0.78] - 2026-10-02 ### Fixed diff --git a/src/api/sync.ts b/src/api/sync.ts index 19fed0c..16a6859 100644 --- a/src/api/sync.ts +++ b/src/api/sync.ts @@ -40,6 +40,8 @@ export interface PaginationMetadata { export interface SyncResponse { sync: { sync_id: string; + project_id?: string; + project_matches?: boolean; status: string; created_at: string; sync_url?: string; @@ -55,20 +57,24 @@ export interface SyncResponse { /** * Get sync translations from the Sync API * @param syncId The sync ID from localhero.json - * @param options Optional pagination parameters + * @param options Optional pagination parameters, and the caller's projectId so the + * response says whether the sync belongs to it (project_matches) * @returns The sync response with translations */ export async function getSyncTranslations( syncId: string, - options: { page?: number; perPage?: number } = {} + options: { page?: number; perPage?: number; projectId?: string } = {} ): Promise { const apiKey = await getApiKey(); - const { page = 1, perPage = 500 } = options; + const { page = 1, perPage = 500, projectId } = options; const queryParams = new URLSearchParams({ page: page.toString(), per_page: perPage.toString() }); + if (projectId) { + queryParams.set('project_id', projectId); + } return apiRequest(`/api/v1/translation_syncs/${syncId}?${queryParams}`, { apiKey }); } diff --git a/src/commands/ci.ts b/src/commands/ci.ts index 60889d7..86875d2 100644 --- a/src/commands/ci.ts +++ b/src/commands/ci.ts @@ -99,11 +99,21 @@ async function runTranslateMode( await translateCommand(translateOptions); } +/** + * In a monorepo every app's job receives the same sync dispatch. Only the app + * whose project owns the sync applies it; the others have nothing to do. + */ +function reportSyncForAnotherProject(deps: CiDependencies, owner: string | undefined, projectId: string): void { + const message = `Nothing to apply here. This sync is for project ${owner}; this directory is project ${projectId}.`; + deps.console.log(deps.githubUtils.isGitHubAction() ? `::notice::${message}` : chalk.blue(message)); +} + /** * Run sync mode - fetch done translations from Sync API and update files */ async function runSyncMode( syncId: string, + projectId: string, deps: CiDependencies, options?: { verbose?: boolean; syncUpdateVersion?: number; skipCommit?: boolean } ): Promise { @@ -121,12 +131,17 @@ async function runSyncMode( try { while (currentPage <= totalPages) { - const response = await syncApi.getSyncTranslations(syncId, { page: currentPage }); + const response = await syncApi.getSyncTranslations(syncId, { page: currentPage, projectId }); if (!response || !response.sync || !response.pagination) { throw new Error(`Invalid response from Sync API for page ${currentPage}`); } + if (response.sync.project_matches === false) { + reportSyncForAnotherProject(deps, response.sync.project_id, projectId); + return; + } + if (currentPage === 1) { syncUrl = response.sync.sync_url; branchName = response.sync.branch_name; @@ -263,7 +278,7 @@ export async function ci( console.log(chalk.blue('šŸ“„ Sync mode detected')); } try { - await runSyncMode(syncTriggerId, deps, { verbose: options.verbose, syncUpdateVersion, skipCommit: options.skipCommit }); + await runSyncMode(syncTriggerId, config.projectId, deps, { verbose: options.verbose, syncUpdateVersion, skipCommit: options.skipCommit }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(chalk.red('\nāœ– Sync failed:', errorMessage)); diff --git a/tests/commands/ci-monorepo.test.ts b/tests/commands/ci-monorepo.test.ts new file mode 100644 index 0000000..20f9773 --- /dev/null +++ b/tests/commands/ci-monorepo.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { ci } from '../../src/commands/ci.js'; +import { configService } from '../../src/utils/config.js'; +import { githubService } from '../../src/utils/github.js'; +import { updateTranslationFile } from '../../src/utils/translation-updater/index.js'; + +const EDITOR = 'newsroom-editor'; +const PORTAL = 'newsroom-portal'; + +function writeApp(repo: string, app: string, config: object, localeDir: string): void { + const dir = path.join(repo, 'apps', app); + mkdirSync(path.join(dir, localeDir), { recursive: true }); + writeFileSync(path.join(dir, 'localhero.json'), JSON.stringify(config, null, 2)); + writeFileSync(path.join(dir, localeDir, 'en.json'), JSON.stringify({ headline: 'News' }, null, 2)); + writeFileSync(path.join(dir, localeDir, 'sv.json'), JSON.stringify({ headline: 'Gamla nyheter' }, null, 2)); +} + +function appConfig(projectId: string, localeDir: string, outputLocales = ['sv']) { + return { projectId, sourceLocale: 'en', outputLocales, translationFiles: { paths: [`${localeDir}/`] } }; +} + +// Stands in for the server: it owns the matching decision, given the caller's projectId. +function editorSyncApi() { + return { + getSyncTranslations: jest.fn(async (_syncId: string, options: { projectId?: string } = {}) => ({ + sync: { + sync_id: 'sync_editor', + project_id: EDITOR, + project_matches: options.projectId === EDITOR, + status: 'completed', + created_at: '2026-10-08T00:00:00Z', + files: [{ + path: 'public/locale/sv.json', + language: 'sv', + translations: [{ key: 'headline', name: 'headline', value: 'Nyheter', updated_at: '2026-10-08T00:00:00Z' }] + }] + }, + pagination: { current_page: 1, total_pages: 1, total_count: 1, next_page: null, prev_page: null, items_per_page: 500 } + })), + completeSyncUpdate: jest.fn(async () => ({ success: true, status: 'completed' })) + }; +} + +describe('ci sync mode in a monorepo with one project per app', () => { + let repo: string; + let originalCwd: string; + let exit: jest.SpiedFunction; + + const read = (relative: string) => readFileSync(path.join(repo, relative), 'utf8'); + + async function runCiIn(app: string, syncApi: ReturnType) { + process.chdir(path.join(repo, 'apps', app)); + await ci({}, { + console: { log: jest.fn(), error: jest.fn() }, + configUtils: configService, + authUtils: { checkAuth: async () => true }, + githubUtils: { ...githubService, isGitHubAction: () => false }, + env: { LOCALHERO_SYNC_ID: 'sync_editor', LOCALHERO_SYNC_VERSION: '2' }, + translateCommand: jest.fn(async () => undefined), + syncApi, + updateTranslationFile + }); + } + + beforeEach(() => { + originalCwd = process.cwd(); + repo = mkdtempSync(path.join(os.tmpdir(), 'ci-monorepo-')); + writeApp(repo, 'editor', appConfig(EDITOR, 'public/locale'), 'public/locale'); + writeApp(repo, 'portal', appConfig(PORTAL, 'public/locales'), 'public/locales'); + exit = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + }); + + afterEach(() => { + exit.mockRestore(); + process.chdir(originalCwd); + rmSync(repo, { recursive: true, force: true }); + }); + + it('the owning app applies the sync to its own files', async () => { + const syncApi = editorSyncApi(); + + await runCiIn('editor', syncApi); + + expect(JSON.parse(read('apps/editor/public/locale/sv.json')).headline).toBe('Nyheter'); + expect(syncApi.completeSyncUpdate).toHaveBeenCalledWith('sync_editor', 2); + expect(read('apps/portal/public/locales/sv.json')).toContain('Gamla nyheter'); + }); + + it('a sibling app leaves every file alone and does not complete the sync', async () => { + const syncApi = editorSyncApi(); + const editorBefore = read('apps/editor/public/locale/sv.json'); + const portalConfigBefore = read('apps/portal/localhero.json'); + + await runCiIn('portal', syncApi); + + expect(read('apps/portal/public/locales/sv.json')).toContain('Gamla nyheter'); + expect(existsSync(path.join(repo, 'apps/portal/public/locale'))).toBe(false); + expect(read('apps/editor/public/locale/sv.json')).toBe(editorBefore); + expect(read('apps/portal/localhero.json')).toBe(portalConfigBefore); + expect(syncApi.completeSyncUpdate).not.toHaveBeenCalled(); + }); + + it('a sibling app with a config that would fail validation still stands down cleanly', async () => { + writeFileSync( + path.join(repo, 'apps/portal/localhero.json'), + JSON.stringify(appConfig(PORTAL, 'public/locales', []), null, 2) + ); + + await expect(runCiIn('portal', editorSyncApi())).resolves.toBeUndefined(); + }); +}); diff --git a/tests/commands/ci.test.ts b/tests/commands/ci.test.ts index 8ce2945..062dba3 100644 --- a/tests/commands/ci.test.ts +++ b/tests/commands/ci.test.ts @@ -620,5 +620,52 @@ describe('ci command', () => { const passedTranslations = deps.updateTranslationFile.mock.calls[0][1]; expect(passedTranslations[0].metadata?.source_references).toEqual(['lib/web.ex:10']); }); + + describe('when the sync belongs to another project in the repository', () => { + const otherProjectsSync = { + sync: { ...syncResponse.sync, project_id: 'editor', project_matches: false }, + pagination: { ...syncResponse.pagination, total_pages: 3, next_page: 2 } + }; + + it('applies nothing, completes nothing and exits cleanly', async () => { + mockEnv.LOCALHERO_SYNC_ID = 'sync_abc'; + mockEnv.LOCALHERO_SYNC_VERSION = '3'; + const deps = buildSyncDeps(); + deps.syncApi.getSyncTranslations.mockResolvedValue(otherProjectsSync); + const exit = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + await ci({}, deps); + + expect(exit).not.toHaveBeenCalled(); + expect(deps.syncApi.getSyncTranslations).toHaveBeenCalledTimes(1); + expect(deps.updateTranslationFile).not.toHaveBeenCalled(); + expect(mockConfigUtils.saveProjectConfig).not.toHaveBeenCalled(); + expect(deps.githubUtils.autoCommitSyncChanges).not.toHaveBeenCalled(); + expect(deps.syncApi.completeSyncUpdate).not.toHaveBeenCalled(); + expect(mockConsole.log).toHaveBeenCalledWith( + '::notice::Nothing to apply here. This sync is for project editor; this directory is project test-project.' + ); + } finally { + exit.mockRestore(); + } + }); + + }); + + it('sends its project with every page so the server can say whose sync it is', async () => { + mockEnv.LOCALHERO_SYNC_ID = 'sync_abc'; + const deps = buildSyncDeps(); + const ownSync = { ...syncResponse.sync, project_id: 'test-project', project_matches: true }; + deps.syncApi.getSyncTranslations + .mockResolvedValueOnce({ sync: ownSync, pagination: { ...syncResponse.pagination, total_pages: 2, next_page: 2 } }) + .mockResolvedValueOnce({ sync: ownSync, pagination: { ...syncResponse.pagination, current_page: 2, total_pages: 2 } }); + + await ci({ skipCommit: true }, deps); + + expect(deps.syncApi.getSyncTranslations).toHaveBeenNthCalledWith(1, 'sync_abc', { page: 1, projectId: 'test-project' }); + expect(deps.syncApi.getSyncTranslations).toHaveBeenNthCalledWith(2, 'sync_abc', { page: 2, projectId: 'test-project' }); + expect(deps.updateTranslationFile).toHaveBeenCalledTimes(2); + }); }); }); From 98caaccc2dd9ee93778628366e1148b65c9a52a9 Mon Sep 17 00:00:00 2001 From: Arvid Andersson Date: Thu, 8 Oct 2026 09:53:51 +0200 Subject: [PATCH 2/2] Run tests inside an agent worktree - Anchor the .claude ignore to , so the main checkout still skips worktree copies (#111) while a worktree no longer ignores itself - Before, npm test in .claude/worktrees/ found no tests at all --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index af270a0..583adcc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,5 +9,5 @@ export default { '**/tests/**/*.test.ts', '**/tests/**/*.test.js' ], - testPathIgnorePatterns: ['/node_modules/', '/.claude/'] + testPathIgnorePatterns: ['/node_modules/', '/.claude/'] }; \ No newline at end of file