diff --git a/.agents/skills/add-e2e-test-suite/SKILL.md b/.agents/skills/add-e2e-test-suite/SKILL.md index 27e358894..905f263af 100644 --- a/.agents/skills/add-e2e-test-suite/SKILL.md +++ b/.agents/skills/add-e2e-test-suite/SKILL.md @@ -1,11 +1,11 @@ --- name: add-e2e-test-suite -description: Adds a new end-to-end (e2e) test suite for the Crowdin CLI under e2e/suites/, exercising real CLI commands against a freshly-created Crowdin project. Covers fixtures, the setupSuite/teardownSuite lifecycle, running the CLI, output normalization, snapshot generation, and file assertions. Use whenever asked to add, write, scaffold, or extend an e2e/integration test suite for the CLI — including new command coverage like upload, download, branch, glossary, or TM — even if the user just says "add an e2e test for X". +description: Adds a new end-to-end (e2e) test suite for the Crowdin CLI under tests/e2e/suites/, exercising real CLI commands against a freshly-created Crowdin project. Covers fixtures, the setupSuite/teardownSuite lifecycle, running the CLI, output normalization, snapshot generation, and file assertions. Use whenever asked to add, write, scaffold, or extend an e2e/integration test suite for the CLI — including new command coverage like upload, download, branch, glossary, or TM — even if the user just says "add an e2e test for X". --- # Add an e2e test suite (Crowdin CLI) -The framework runs `bun src-next/cli.ts` against a **real, freshly-created Crowdin project**, asserts on normalized output / exit codes / produced files, then tears everything down. Each suite is one file owning one project: `beforeAll` provisions, `test()`s run **in declaration order**, `afterAll` tears down. See [e2e/README.md](../../../e2e/README.md). +The framework runs `bun src-next/cli.ts` against a **real, freshly-created Crowdin project**, asserts on normalized output / exit codes / produced files, then tears everything down. Each suite is one file owning one project: `beforeAll` provisions, `test()`s run **in declaration order**, `afterAll` tears down. See [tests/e2e/README.md](../../../tests/e2e/README.md). ## Iron rule: generate snapshots from a real run, never hand-write them @@ -15,7 +15,7 @@ You can't reliably predict the CLI's exact output or where the server lands file ```bash # user runs this (token already in their env): -bun test e2e/suites/.test.ts --update-snapshots +bun test tests/e2e/suites/.test.ts --update-snapshots ``` Then read the committed `.snap` to sanity-check it's real output, not an error/empty build. @@ -24,7 +24,7 @@ Same for any server behavior (locale folder names, file layout): **observe it, d ## Steps -**1. Fixtures** — `e2e/fixtures//config/crowdin.yml` (template) + input files (e.g. `sources/*.md`). Use only `{{projectId}}` / `{{token}}` placeholders; `renderConfig` throws on any other `{{...}}`. Everything except the top-level `config/` dir is copied into the workspace. +**1. Fixtures** — `tests/e2e/fixtures//config/crowdin.yml` (template) + input files (e.g. `sources/*.md`). Use only `{{projectId}}` / `{{token}}` placeholders; `renderConfig` throws on any other `{{...}}`. Everything except the top-level `config/` dir is copied into the workspace. ```yaml project_id: "{{projectId}}" @@ -37,7 +37,7 @@ files: translation: "translations/%locale%/%original_file_name%" ``` -**2. Suite** — `e2e/suites/.test.ts`: +**2. Suite** — `tests/e2e/suites/.test.ts`: ```ts import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; @@ -82,7 +82,7 @@ No registry to edit (suites are discovered by file). No file cleanup to write (` - **Prefer literal assertion strings** (`'it/sources/alpha.md'`) over paths derived from the API/config — clearer and obviously correct. - **Token required.** `setupSuite` throws without `CROWDIN_E2E_TOKEN`. Suites run via `bun run test:e2e`; the network-free helper unit tests run in the regular `bun test`. -## Helpers (`e2e/helpers/`) +## Helpers (`tests/e2e/helpers/`) - `setupSuite(suite, { sourceLanguageId?, targetLanguageIds? })` → `SuiteContext { env, client, workspace, project, runner }`. Provisions workspace + fixtures + project + rendered config; rolls back the project if a later setup step fails. `ctx.client` is a `@crowdin/crowdin-api-client` `Client` for direct API setup/assertions. - `teardownSuite(ctx)` — deletes project + removes workspace; honors `CROWDIN_E2E_KEEP=1`; logs, never throws. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b787469cf..7cdfa7b65 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,5 +34,5 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-snapshots - path: e2e/suites/__snapshots__/ + path: tests/e2e/suites/__snapshots__/ if-no-files-found: ignore diff --git a/biome.json b/biome.json index bd27570fc..21f22c1d8 100644 --- a/biome.json +++ b/biome.json @@ -23,6 +23,6 @@ }, "files": { "ignoreUnknown": false, - "includes": ["src-next/**/*.ts", "tests/**/*.ts", "e2e/**/*.ts", "packages/npm/cli/bin/*.js"] + "includes": ["src-next/**/*.ts", "tests/**/*.ts", "packages/npm/cli/bin/*.js"] } } diff --git a/e2e/helpers/cli.ts b/e2e/helpers/cli.ts deleted file mode 100644 index ee91c73b9..000000000 --- a/e2e/helpers/cli.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { join } from 'node:path'; - -export interface CliResult { - stdout: string; - stderr: string; - exitCode: number; - /** True when the call was killed by the per-run timeout rather than exiting on its own. */ - timedOut: boolean; -} - -export interface CliRunOptions { - /** Extra environment variables merged onto the current process env. */ - env?: Record; - /** Working directory; defaults to the workspace. */ - cwd?: string; - /** Skip the auto-appended `-c --no-progress --no-colors` flags. */ - noConfig?: boolean; - /** Per-call timeout in milliseconds. */ - timeoutMs?: number; -} - -const DEFAULT_TIMEOUT_MS = 120_000; -const REPO_ROOT = join(import.meta.dir, '..', '..'); - -/** The single way the suites invoke the CLI: the source entry point through bun. */ -const CLI_COMMAND = ['bun', join(REPO_ROOT, 'src-next', 'cli.ts')]; - -export class CliRunner { - constructor(private readonly opts: { workspace: string; configPath: string }) {} - - async run(args: string[], runOpts: CliRunOptions = {}): Promise { - const fullArgs = [...args]; - - if (!runOpts.noConfig) { - fullArgs.push('-c', this.opts.configPath, '--no-progress', '--no-colors'); - } - - const proc = Bun.spawn([...CLI_COMMAND, ...fullArgs], { - cwd: runOpts.cwd ?? this.opts.workspace, - env: { ...process.env, ...runOpts.env }, - stdout: 'pipe', - stderr: 'pipe', - }); - - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - proc.kill(); - }, runOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS); - - try { - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - return { stdout, stderr, exitCode, timedOut }; - } finally { - clearTimeout(timeout); - } - } -} diff --git a/e2e/helpers/files.ts b/e2e/helpers/files.ts deleted file mode 100644 index 30777a999..000000000 --- a/e2e/helpers/files.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect } from 'bun:test'; -import { join } from 'node:path'; - -/** - * Assert that every given path (relative to `workspace`) exists. Reports all - * missing paths at once instead of failing on the first. - */ -export async function expectFilesExist(workspace: string, ...relativePaths: string[]): Promise { - const missing: string[] = []; - - for (const relativePath of relativePaths) { - if (!(await Bun.file(join(workspace, relativePath)).exists())) { - missing.push(relativePath); - } - } - - expect(missing).toEqual([]); -} diff --git a/e2e/helpers/normalize.test.ts b/e2e/helpers/normalize.test.ts deleted file mode 100644 index 711843fb0..000000000 --- a/e2e/helpers/normalize.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { normalize } from './normalize.ts'; - -describe('normalize', () => { - test('strips ANSI color/control sequences', () => { - expect(normalize('\x1b[32mhello\x1b[0m')).toBe('hello'); - }); - - test('removes zero-width invisible characters', () => { - expect(normalize('a\u200Bb\uFEFF')).toBe('ab'); - }); - - test('masks #-prefixed ids but leaves bare counts alone', () => { - expect(normalize('Created string #12345')).toBe('Created string #id'); - expect(normalize('Uploaded 3 files')).toBe('Uploaded 3 files'); - }); - - test('masks durations', () => { - expect(normalize('done in 1.23s')).toBe('done in '); - expect(normalize('took 450ms')).toBe('took '); - }); - - test('sorts siblings within a marker block so emission order is irrelevant', () => { - const a = normalize('◆ file b\n◆ file a\n◆ file c'); - const b = normalize('◆ file c\n◆ file b\n◆ file a'); - expect(a).toBe(b); - expect(a).toBe('◆ file a\n◆ file b\n◆ file c'); - }); - - test('sorts within each marker block but keeps the blocks in emission order', () => { - const raw = ['● Project info fetched', '● Fetching project info', '◆ File b created', '◆ File a created'].join( - '\n', - ); - expect(normalize(raw)).toBe( - ['● Fetching project info', '● Project info fetched', '◆ File a created', '◆ File b created'].join('\n'), - ); - }); - - test('does not reorder across blocks even when a later block sorts first globally', () => { - // A naive global sort would float the ◆ lines above the ● lines (◆ < ●); - // grouping keeps the ● block first because it was emitted first. - expect(normalize('● b\n● a\n◆ b\n◆ a')).toBe('● a\n● b\n◆ a\n◆ b'); - }); - - test('drops blank lines and trailing whitespace', () => { - expect(normalize('a \n\nb')).toBe('a\nb'); - }); -}); diff --git a/e2e/helpers/normalize.ts b/e2e/helpers/normalize.ts deleted file mode 100644 index 136d0ee4e..000000000 --- a/e2e/helpers/normalize.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Black-box output normalization. Every suite calls `normalize(output)` before - * snapshotting - no per-suite configuration. The CLI emits colors, generated - * ids, timings, and parallel per-file lines in nondeterministic order, none of - * which are snapshot-stable, so `normalize`: - * - * 1. strips ANSI/invisible characters, - * 2. masks generated ids (`#123` → `#id`) and durations (`1.2s` → ``), - * 3. sorts lines *within* each contiguous run of same-marker lines. - * - * Only the parallel result lines (`◆ File … created`, emitted by concurrent - * uploads) are actually unordered; the leading progress lines (`● Fetching …`) - * are sequential. Sorting the whole output would interleave those blocks and - * misrepresent the real flow, so instead we group by leading marker and sort - * each block in place - the progress block stays above the results block, and - * siblings within a block become order-independent. - * - * Because only siblings are sorted, snapshots don't guard the *ordering within* - * a block - suites assert load-bearing facts (counts, messages, exit codes) - * explicitly instead. - */ - -// CSI/SGR escape sequences plus standalone ESC-prefixed control sequences. -// biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal control codes is the point. -const ANSI = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; -// Zero-width space/non-joiner/joiner (U+200B–U+200D) and BOM (U+FEFF). -const INVISIBLE = /[\u200B-\u200D\uFEFF]/g; -// `#123`-style identifiers (string/file ids) without touching bare counts. -const IDS = /#\d+/g; -// Timing/speed values like `1.23s` or `450ms`. -const DURATIONS = /\d+(?:\.\d+)?\s?m?s\b/g; - -/** - * Group key for a line: its leading whitespace-delimited token, which is the - * status marker (`◆`, `●`, …) for CLI status lines. Lines that share a marker - * form one sortable block; an unmarked line groups with adjacent lines sharing - * its first token, otherwise stands alone in emission order. - */ -function groupKey(line: string): string { - return line.match(/^(\S+)\s/)?.[1] ?? line; -} - -export function normalize(output: string): string { - const lines = output - .replace(ANSI, '') - .replace(INVISIBLE, '') - .replace(IDS, '#id') - .replace(DURATIONS, '') - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => line.length > 0); - - const result: string[] = []; - for (let start = 0; start < lines.length; ) { - const key = groupKey(lines[start] as string); - let end = start + 1; - while (end < lines.length && groupKey(lines[end] as string) === key) { - end++; - } - result.push(...lines.slice(start, end).sort()); - start = end; - } - - return result.join('\n'); -} diff --git a/e2e/helpers/suite.ts b/e2e/helpers/suite.ts deleted file mode 100644 index 4e97415ab..000000000 --- a/e2e/helpers/suite.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { join } from 'node:path'; -import type { Client } from '@crowdin/crowdin-api-client'; -import { CliRunner } from './cli.ts'; -import { writeConfig } from './config.ts'; -import type { E2eEnv } from './env.ts'; -import { resolveEnv } from './env.ts'; -import { createApiClient, createTestProject, deleteTestProject, type TestProject } from './project.ts'; -import { copyFixtures, createWorkspace, removeWorkspace } from './workspace.ts'; - -/** Fixtures live at `e2e/fixtures/`, resolved relative to this helper. */ -const FIXTURES_ROOT = join(import.meta.dir, '..', 'fixtures'); - -export interface SuiteContext { - env: E2eEnv; - client: Client; - workspace: string; - project: TestProject; - runner: CliRunner; -} - -export interface SetupSuiteOptions { - sourceLanguageId?: string; - targetLanguageIds?: string[]; -} - -/** - * Compose the per-suite lifecycle: temp workspace, fixtures copied from - * `e2e/fixtures/`, a fresh Crowdin project, and a rendered `crowdin.yml` - * wired into a `CliRunner`. Call from `beforeAll` with the suite name. - */ -export async function setupSuite(suite: string, opts: SetupSuiteOptions = {}): Promise { - const env = resolveEnv(); - const token = env.token; - - if (!token) { - throw new Error('CROWDIN_E2E_TOKEN is not set. E2E suites require a dedicated test-account token.'); - } - - const client = createApiClient(env); - const fixturesDir = join(FIXTURES_ROOT, suite); - const workspace = await createWorkspace(suite); - await copyFixtures(fixturesDir, workspace); - - const project = await createTestProject(client, { - suite, - sourceLanguageId: opts.sourceLanguageId, - targetLanguageIds: opts.targetLanguageIds, - }); - - // Everything past project creation can fail; if it does, tear down what we - // already provisioned so a partial setup doesn't orphan the project (or - // workspace) on the real account. - try { - const template = await Bun.file(join(fixturesDir, 'config', 'crowdin.yml')).text(); - const configPath = await writeConfig(workspace, template, { projectId: project.id, token }); - - const runner = new CliRunner({ workspace, configPath }); - return { env, client, workspace, project, runner }; - } catch (error) { - await teardownSuite({ env, client, workspace, project }); - throw error; - } -} - -/** - * Tear down a suite: delete the project and remove the workspace (which holds - * everything the suite produced, including downloaded files). Honors - * `CROWDIN_E2E_KEEP=1`. Cleanup failures are logged, never thrown, so one failed - * deletion can't mask a real test result. Call from `afterAll`. - */ -export async function teardownSuite( - ctx: Pick | undefined, -): Promise { - if (!ctx) { - return; - } - - if (ctx.env.keep) { - console.log(`CROWDIN_E2E_KEEP=1 - keeping project #${ctx.project.id} (${ctx.project.name}) and ${ctx.workspace}`); - return; - } - - try { - await deleteTestProject(ctx.client, ctx.project.id); - } catch (error) { - console.error(`Failed to delete project #${ctx.project.id}: ${error instanceof Error ? error.message : error}`); - } - - try { - await removeWorkspace(ctx.workspace); - } catch (error) { - console.error(`Failed to remove workspace ${ctx.workspace}: ${error instanceof Error ? error.message : error}`); - } -} diff --git a/knip.jsonc b/knip.jsonc index bcc2232a5..961a4613e 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -2,5 +2,5 @@ "$schema": "https://unpkg.com/knip@6/schema.json", // Scope to the new CLI and its tests. Entry points are inferred from the package.json scripts, // so only `project` needs narrowing - knip's default would pull in website/ and packages/. - "project": ["src-next/**/*.ts", "tests/**/*.ts", "e2e/**/*.ts"] + "project": ["src-next/**/*.ts", "tests/**/*.ts"] } diff --git a/package.json b/package.json index a5e90745d..2e4eb9e0d 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ "build:linux-arm64-musl": "bun build src-next/cli.ts --compile --target=bun-linux-arm64-musl --outfile packages/npm/linux-arm64-musl/bin/crowdin", "build:win32-x64": "bun build src-next/cli.ts --compile --target=bun-windows-x64-baseline --outfile packages/npm/win32-x64/bin/crowdin.exe", "build:all": "bun run build:darwin-arm64 && bun run build:darwin-x64 && bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:linux-x64-musl && bun run build:linux-arm64-musl && bun run build:win32-x64", - "test": "bun test tests/ e2e/helpers/", - "test:coverage": "bun test --coverage tests/ e2e/helpers/", - "test:e2e": "bun test e2e/suites/ --timeout 120000", + "test": "bun test tests/unit/ tests/e2e/helpers/", + "test:coverage": "bun test --coverage tests/unit/ tests/e2e/helpers/", + "test:e2e": "bun test tests/e2e/suites/ --timeout 120000 --parallel=4", "docs": "bun run src-next/scripts/generate-docs.ts", "format": "biome format --write", "lint": "biome check", diff --git a/src-next/cli.ts b/src-next/cli.ts index c5ac6d9e8..fe21d08d5 100644 --- a/src-next/cli.ts +++ b/src-next/cli.ts @@ -9,6 +9,7 @@ import { description, name, version } from './cli/meta.ts'; import getGlobalOptions from './cli/options.ts'; import { expandArgFiles } from './cli/utils/argFiles.ts'; import { checkNewVersion } from './cli/utils/checkVersion.ts'; +import { enableColors } from './cli/utils/colors.ts'; import { isStructuredFormat } from './cli/utils/formatter.ts'; import { createOutput, getOutputFormatFromArgs } from './cli/utils/output.ts'; @@ -88,6 +89,9 @@ const argv = [...process.argv.slice(0, 2), ...(isCompletion ? rawArgs : expandAr const globalOptions = getOutputFormatFromArgs(argv); const isStructured = isStructuredFormat(globalOptions.output); +// Help and usage errors print before any action creates an Output, so apply --no-colors up front. +enableColors(globalOptions.colors && globalOptions.output === 'text'); + try { await main(argv, isStructured); // Help, version, empty args and parse errors all throw CommanderError, so they skip the version check. @@ -113,7 +117,7 @@ try { if (globalOptions.debug && error instanceof Error && error.stack) { // ponytail: top-level only; per-file failures in upload/download still print just their message. - console.error(error.stack); + process.stderr.write(`${error.stack}\n`); } else if (isStructured || !(error instanceof CliError && error.reported)) { // `reported` means "already shown to a human" — a spinner line, or a command's own printed // message. json/toon have no such affordance, so the record is always written here, the only diff --git a/src-next/cli/commands/upload/UploadTranslationsCommand.ts b/src-next/cli/commands/upload/UploadTranslationsCommand.ts index 136f9c9a9..f4dbe230d 100644 --- a/src-next/cli/commands/upload/UploadTranslationsCommand.ts +++ b/src-next/cli/commands/upload/UploadTranslationsCommand.ts @@ -143,7 +143,6 @@ export default class UploadTranslationsCommand { try { output.info(this.importingMessage(entry.translationPath)); - // importProjectTranslation returns only once the server-side import has finished. await translationService.importProjectTranslation( storage.data.id, entry.fileId as number, diff --git a/src-next/cli/utils/output.ts b/src-next/cli/utils/output.ts index 19cee2402..e2076e6ed 100644 --- a/src-next/cli/utils/output.ts +++ b/src-next/cli/utils/output.ts @@ -107,6 +107,11 @@ export function createOutput(options: GlobalOptions, { withGuide = false }: Outp * a command that warns about three files and then fails still leaves parseable output behind. */ function diagnostic(level: 'error' | 'warning', message: string, code?: number): void { + // Not console.error: bun paints the whole line red under FORCE_COLOR, whatever --no-colors says. + process.stderr.write(`${diagnosticLine(level, message, code)}\n`); + } + + function diagnosticLine(level: 'error' | 'warning', message: string, code?: number): string { // One record per diagnostic, emitted as it happens, so a killed run keeps the warnings it // already produced. json separates records by newline, toon by blank line; both escape newlines // inside a message, so neither separator can appear within a record. TOON's list form is out: @@ -116,19 +121,17 @@ export function createOutput(options: GlobalOptions, { withGuide = false }: Outp // Not formatData for json: it indents for readability on stdout, which would spread one // record over several lines and take the newline separator with it. - console.error(format === 'toon' ? `${formatData(record, format)}\n` : JSON.stringify(record)); - return; + return format === 'toon' ? `${formatData(record, format)}\n` : JSON.stringify(record); } // plain drops the symbol for the same reason its views do: the line is the contract. if (format === 'plain') { - console.error(message); - return; + return message; } const symbol = level === 'error' ? colors.red(S_ERROR) : colors.yellow(S_WARN); - console.error(`${symbol} ${message}`); + return `${symbol} ${message}`; } return { diff --git a/src-next/lib/config.ts b/src-next/lib/config.ts index d4b5db7b0..c4eb5e08c 100644 --- a/src-next/lib/config.ts +++ b/src-next/lib/config.ts @@ -25,6 +25,22 @@ function normalizeBaseUrl(url: string): string { return url.replace(/\/(api(\/|\/v2\/?)?)?$/, ''); } +/** + * Separators collapse to '/', and the pattern gets a * leading '/' unless it belongs to a multilingual file + * with no language placeholder. The leading separator matters on the wire — Crowdin silently ignores a file + * export pattern that doesn't start with '/' and falls back to the default '/%locale%/%original_path%'. + */ +function normalizeTranslation(file: { translation: string; scheme?: unknown; multilingual?: boolean }): string { + const normalized = file.translation.replace(/[\\/]+/g, '/'); + const multilingual = file.scheme !== undefined || file.multilingual === true; + + if (multilingual && !languagePatterns.some((pattern) => normalized.includes(pattern))) { + return normalized.replace(/^\/+/, ''); + } + + return normalized.startsWith('/') ? normalized : `/${normalized}`; +} + // Accepts 0/1 and their string forms in addition to real booleans. const coercedBoolean = z.preprocess((value) => { if (value === 1 || value === '1' || value === true || value === 'true') { diff --git a/tests/cli/builder.test.ts b/tests/cli/builder.test.ts deleted file mode 100644 index 355e76b00..000000000 --- a/tests/cli/builder.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { Command } from 'commander'; -import { buildOption } from '@/cli/builder.ts'; -import type { OptionDef } from '@/cli/types.ts'; - -const language: OptionDef = { - name: 'language', - short: 'l', - type: 'string', - variadic: true, - required: true, - description: 'Target language identifier', -}; - -describe('buildOption', () => { - test('marks a required option mandatory, as picocli does', () => { - const command = new Command('add').exitOverride().addOption(buildOption(language)); - - expect(() => command.parse([], { from: 'user' })).toThrow(/required option .* not specified/); - expect(() => command.parse(['-l', 'uk'], { from: 'user' })).not.toThrow(); - }); -}); diff --git a/e2e/README.md b/tests/e2e/README.md similarity index 94% rename from e2e/README.md rename to tests/e2e/README.md index d2eb7a71b..fae940b01 100644 --- a/e2e/README.md +++ b/tests/e2e/README.md @@ -7,7 +7,7 @@ End-to-end tests that run the CLI against a real, freshly-created Crowdin projec ```bash export CROWDIN_E2E_TOKEN=xxxxxxxx... bun run test:e2e -bun test e2e/ --update-snapshots +bun test tests/e2e/suites/ --update-snapshots ``` ## Environment @@ -22,7 +22,7 @@ The suites always run the CLI via `bun src-next/cli.ts` - locally and in CI. ## Layout ``` -e2e/ +tests/e2e/ helpers/ # env, workspace, config, cli, normalize, project, suite (+ unit tests) fixtures/ # /config/crowdin.yml template + source files (e.g. sources/) suites/ # one self-contained suite per file; each owns one project diff --git a/tests/e2e/fixtures/app/alt-configs/no-project-id.yml b/tests/e2e/fixtures/app/alt-configs/no-project-id.yml new file mode 100644 index 000000000..f29dcc348 --- /dev/null +++ b/tests/e2e/fixtures/app/alt-configs/no-project-id.yml @@ -0,0 +1,5 @@ +# `app` sits in the project tier (`projectConfigGroup` in cli/commands/common/options.ts), so +# `project_id` is required even though none of the three subcommands sends one to the API. +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/app/config/crowdin.yml b/tests/e2e/fixtures/app/config/crowdin.yml new file mode 100644 index 000000000..576449444 --- /dev/null +++ b/tests/e2e/fixtures/app/config/crowdin.yml @@ -0,0 +1,4 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/auto-translate-mt/config/crowdin.yml b/tests/e2e/fixtures/auto-translate-mt/config/crowdin.yml new file mode 100644 index 000000000..b83c55ecf --- /dev/null +++ b/tests/e2e/fixtures/auto-translate-mt/config/crowdin.yml @@ -0,0 +1,9 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/auto-translate-mt/sources/1_android.xml b/tests/e2e/fixtures/auto-translate-mt/sources/1_android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/auto-translate-mt/sources/1_android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/auto-translate-mt/sources/2_android.xml b/tests/e2e/fixtures/auto-translate-mt/sources/2_android.xml new file mode 100644 index 000000000..3db26f896 --- /dev/null +++ b/tests/e2e/fixtures/auto-translate-mt/sources/2_android.xml @@ -0,0 +1,6 @@ + + + first string + second string + third string + diff --git a/tests/e2e/fixtures/auto-translate/config/crowdin.yml b/tests/e2e/fixtures/auto-translate/config/crowdin.yml new file mode 100644 index 000000000..fc29a82c7 --- /dev/null +++ b/tests/e2e/fixtures/auto-translate/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/**/*.xml" + translation: "/translations/%two_letters_code%/%original_path%/%original_file_name%" diff --git a/tests/e2e/fixtures/auto-translate/sources/app.xml b/tests/e2e/fixtures/auto-translate/sources/app.xml new file mode 100644 index 000000000..dac2bd9fa --- /dev/null +++ b/tests/e2e/fixtures/auto-translate/sources/app.xml @@ -0,0 +1,5 @@ + + + Welcome aboard + Log out + diff --git a/tests/e2e/fixtures/auto-translate/sources/nested/extra.xml b/tests/e2e/fixtures/auto-translate/sources/nested/extra.xml new file mode 100644 index 000000000..e205bb5a9 --- /dev/null +++ b/tests/e2e/fixtures/auto-translate/sources/nested/extra.xml @@ -0,0 +1,4 @@ + + + Nested string + diff --git a/tests/e2e/fixtures/auto-update/config/crowdin.yml b/tests/e2e/fixtures/auto-update/config/crowdin.yml new file mode 100644 index 000000000..e37ce7dcc --- /dev/null +++ b/tests/e2e/fixtures/auto-update/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/auto-update/sources/1_android.xml b/tests/e2e/fixtures/auto-update/sources/1_android.xml new file mode 100644 index 000000000..a0cd0aed4 --- /dev/null +++ b/tests/e2e/fixtures/auto-update/sources/1_android.xml @@ -0,0 +1,9 @@ + + + first string source file1 + second string source file1 + third string source file1 + fourth string source file1 + fifth string source file1 + + diff --git a/tests/e2e/fixtures/auto-update/sources/2_android.xml b/tests/e2e/fixtures/auto-update/sources/2_android.xml new file mode 100644 index 000000000..cb2c0ec5a --- /dev/null +++ b/tests/e2e/fixtures/auto-update/sources/2_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/auto-update/sources_rev2/1_android.xml b/tests/e2e/fixtures/auto-update/sources_rev2/1_android.xml new file mode 100644 index 000000000..8c0304bab --- /dev/null +++ b/tests/e2e/fixtures/auto-update/sources_rev2/1_android.xml @@ -0,0 +1,10 @@ + + + first string source revision2 file1 + second string source revision2 file1 + third string source revision2 file1 + fourth string source revision2 file1 + fifth string source revision2 file1 + sixth string source revision2 file1 + + diff --git a/tests/e2e/fixtures/auto-update/sources_rev2/2_android.xml b/tests/e2e/fixtures/auto-update/sources_rev2/2_android.xml new file mode 100644 index 000000000..cb2c0ec5a --- /dev/null +++ b/tests/e2e/fixtures/auto-update/sources_rev2/2_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/auto-update/sources_rev2/3_android.xml b/tests/e2e/fixtures/auto-update/sources_rev2/3_android.xml new file mode 100644 index 000000000..cb2c0ec5a --- /dev/null +++ b/tests/e2e/fixtures/auto-update/sources_rev2/3_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/auto-update/sources_rev2/4_android.xml b/tests/e2e/fixtures/auto-update/sources_rev2/4_android.xml new file mode 100644 index 000000000..c66380a38 --- /dev/null +++ b/tests/e2e/fixtures/auto-update/sources_rev2/4_android.xml @@ -0,0 +1,6 @@ + + + first string + second string + + diff --git a/tests/e2e/fixtures/base-path/alt-configs/relative-base-path.yml b/tests/e2e/fixtures/base-path/alt-configs/relative-base-path.yml new file mode 100644 index 000000000..a8643caa3 --- /dev/null +++ b/tests/e2e/fixtures/base-path/alt-configs/relative-base-path.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/src/main/res/values/*.xml" + translation: "/src/main/res/values-%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/base-path/config/crowdin.yml b/tests/e2e/fixtures/base-path/config/crowdin.yml new file mode 100644 index 000000000..0a7e0442a --- /dev/null +++ b/tests/e2e/fixtures/base-path/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/files/src/main/res/values/*.xml" + translation: "/files/src/main/res/values-%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/base-path/dev/files/src/main/res/values-it/android.xml b/tests/e2e/fixtures/base-path/dev/files/src/main/res/values-it/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/base-path/dev/files/src/main/res/values-it/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/base-path/dev/files/src/main/res/values-uk/android.xml b/tests/e2e/fixtures/base-path/dev/files/src/main/res/values-uk/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/base-path/dev/files/src/main/res/values-uk/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/base-path/dev/files/src/main/res/values/android.xml b/tests/e2e/fixtures/base-path/dev/files/src/main/res/values/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/base-path/dev/files/src/main/res/values/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/base-path/files/src/main/res/values-it/android.xml b/tests/e2e/fixtures/base-path/files/src/main/res/values-it/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/base-path/files/src/main/res/values-it/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/base-path/files/src/main/res/values-uk/android.xml b/tests/e2e/fixtures/base-path/files/src/main/res/values-uk/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/base-path/files/src/main/res/values-uk/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/base-path/files/src/main/res/values/android.xml b/tests/e2e/fixtures/base-path/files/src/main/res/values/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/base-path/files/src/main/res/values/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/e2e/fixtures/basic-upload-download/config/crowdin.yml b/tests/e2e/fixtures/basic-upload-download/config/crowdin.yml similarity index 100% rename from e2e/fixtures/basic-upload-download/config/crowdin.yml rename to tests/e2e/fixtures/basic-upload-download/config/crowdin.yml diff --git a/e2e/fixtures/basic-upload-download/sources/alpha.md b/tests/e2e/fixtures/basic-upload-download/sources/alpha.md similarity index 100% rename from e2e/fixtures/basic-upload-download/sources/alpha.md rename to tests/e2e/fixtures/basic-upload-download/sources/alpha.md diff --git a/e2e/fixtures/basic-upload-download/sources/beta.md b/tests/e2e/fixtures/basic-upload-download/sources/beta.md similarity index 100% rename from e2e/fixtures/basic-upload-download/sources/beta.md rename to tests/e2e/fixtures/basic-upload-download/sources/beta.md diff --git a/e2e/fixtures/basic-upload-download/sources/gamma.md b/tests/e2e/fixtures/basic-upload-download/sources/gamma.md similarity index 100% rename from e2e/fixtures/basic-upload-download/sources/gamma.md rename to tests/e2e/fixtures/basic-upload-download/sources/gamma.md diff --git a/tests/e2e/fixtures/branch/config/crowdin.yml b/tests/e2e/fixtures/branch/config/crowdin.yml new file mode 100644 index 000000000..576449444 --- /dev/null +++ b/tests/e2e/fixtures/branch/config/crowdin.yml @@ -0,0 +1,4 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/branches/alt-configs/sources-rev2.yml b/tests/e2e/fixtures/branches/alt-configs/sources-rev2.yml new file mode 100644 index 000000000..a9e5bb8b6 --- /dev/null +++ b/tests/e2e/fixtures/branches/alt-configs/sources-rev2.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "sources_rev2/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/branches/alt-configs/sources.yml b/tests/e2e/fixtures/branches/alt-configs/sources.yml new file mode 100644 index 000000000..5cff7f40c --- /dev/null +++ b/tests/e2e/fixtures/branches/alt-configs/sources.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/branches/config/crowdin.yml b/tests/e2e/fixtures/branches/config/crowdin.yml new file mode 100644 index 000000000..ee2f83a8b --- /dev/null +++ b/tests/e2e/fixtures/branches/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources_one_file/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/branches/expected/it/1_android.xml b/tests/e2e/fixtures/branches/expected/it/1_android.xml new file mode 100644 index 000000000..4fce04a90 --- /dev/null +++ b/tests/e2e/fixtures/branches/expected/it/1_android.xml @@ -0,0 +1,9 @@ + + + la prima riga stringa revision2 file1 + seconda riga sorgente revision2 file1 + terza stringa sorgente revision2 file1 + quarta stringa sorgente revision2 file1 + quinta riga stringa revision2 file1 + sesta riga sorgente revision2 file1 + diff --git a/tests/e2e/fixtures/branches/expected/it/2_android.xml b/tests/e2e/fixtures/branches/expected/it/2_android.xml new file mode 100644 index 000000000..a69adf51e --- /dev/null +++ b/tests/e2e/fixtures/branches/expected/it/2_android.xml @@ -0,0 +1,9 @@ + + + la prima riga stringa revision2 file2 + seconda riga sorgente revision2 file2 + terza stringa sorgente revision2 file2 + quarta stringa sorgente revision2 file2 + quinta riga stringa revision2 file2 + sesta riga sorgente revision2 file2 + diff --git a/tests/e2e/fixtures/branches/expected/uk/1_android.xml b/tests/e2e/fixtures/branches/expected/uk/1_android.xml new file mode 100644 index 000000000..53ed9a7b6 --- /dev/null +++ b/tests/e2e/fixtures/branches/expected/uk/1_android.xml @@ -0,0 +1,9 @@ + + + джерело першої стрічки версія2 файл1 + джерело другої стрічки версія2 файл1 + джерело третьої стрічки версія2 файл1 + джерело четвертої стрічки версія2 файл1 + джерело п\'ятої стрічки версія2 файл1 + джерело шостої стрічки версія2 файл1 + diff --git a/tests/e2e/fixtures/branches/expected/uk/2_android.xml b/tests/e2e/fixtures/branches/expected/uk/2_android.xml new file mode 100644 index 000000000..8c47a4992 --- /dev/null +++ b/tests/e2e/fixtures/branches/expected/uk/2_android.xml @@ -0,0 +1,9 @@ + + + джерело першої стрічки версія2 файл2 + джерело другої стрічки версія2 файл2 + джерело третьої стрічки версія2 файл2 + джерело четвертої стрічки версія2 файл2 + джерело п\'ятої стрічки версія2 файл2 + джерело шостої стрічки версія2 файл2 + diff --git a/tests/e2e/fixtures/branches/sources/1_android.xml b/tests/e2e/fixtures/branches/sources/1_android.xml new file mode 100644 index 000000000..b69a70c02 --- /dev/null +++ b/tests/e2e/fixtures/branches/sources/1_android.xml @@ -0,0 +1,8 @@ + + + first string source file1 + second string source file1 + third string source file1 + fourth string source file1 + fifth string source file1 + diff --git a/tests/e2e/fixtures/branches/sources/2_android.xml b/tests/e2e/fixtures/branches/sources/2_android.xml new file mode 100644 index 000000000..1dd848b31 --- /dev/null +++ b/tests/e2e/fixtures/branches/sources/2_android.xml @@ -0,0 +1,9 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + diff --git a/tests/e2e/fixtures/branches/sources/ignored.json b/tests/e2e/fixtures/branches/sources/ignored.json new file mode 100644 index 000000000..c47f7bdf1 --- /dev/null +++ b/tests/e2e/fixtures/branches/sources/ignored.json @@ -0,0 +1,11 @@ +{ + "apply": { + "message": "Apply" + }, + "cancel": { + "message": "Cancel" + }, + "reset": { + "message": "Rest" + } +} diff --git a/tests/e2e/fixtures/branches/sources_one_file/1_android.xml b/tests/e2e/fixtures/branches/sources_one_file/1_android.xml new file mode 100644 index 000000000..b69a70c02 --- /dev/null +++ b/tests/e2e/fixtures/branches/sources_one_file/1_android.xml @@ -0,0 +1,8 @@ + + + first string source file1 + second string source file1 + third string source file1 + fourth string source file1 + fifth string source file1 + diff --git a/tests/e2e/fixtures/branches/sources_rev2/1_android.xml b/tests/e2e/fixtures/branches/sources_rev2/1_android.xml new file mode 100644 index 000000000..96cf97111 --- /dev/null +++ b/tests/e2e/fixtures/branches/sources_rev2/1_android.xml @@ -0,0 +1,9 @@ + + + first string source revision2 file1 + second string source revision2 file1 + third string source revision2 file1 + fourth string source revision2 file1 + fifth string source revision2 file1 + sixth string source revision2 file1 + diff --git a/tests/e2e/fixtures/branches/sources_rev2/2_android.xml b/tests/e2e/fixtures/branches/sources_rev2/2_android.xml new file mode 100644 index 000000000..ebe3b54c4 --- /dev/null +++ b/tests/e2e/fixtures/branches/sources_rev2/2_android.xml @@ -0,0 +1,9 @@ + + + first string source revision2 file2 + second string source revision2 file2 + third string source revision2 file2 + fourth string source revision2 file2 + fifth string source revision2 file2 + sixth string source revision2 file2 + diff --git a/tests/e2e/fixtures/branches/sources_rev2/ignored.json b/tests/e2e/fixtures/branches/sources_rev2/ignored.json new file mode 100644 index 000000000..c47f7bdf1 --- /dev/null +++ b/tests/e2e/fixtures/branches/sources_rev2/ignored.json @@ -0,0 +1,11 @@ +{ + "apply": { + "message": "Apply" + }, + "cancel": { + "message": "Cancel" + }, + "reset": { + "message": "Rest" + } +} diff --git a/tests/e2e/fixtures/branches/translations/it/1_android.xml b/tests/e2e/fixtures/branches/translations/it/1_android.xml new file mode 100644 index 000000000..4fce04a90 --- /dev/null +++ b/tests/e2e/fixtures/branches/translations/it/1_android.xml @@ -0,0 +1,9 @@ + + + la prima riga stringa revision2 file1 + seconda riga sorgente revision2 file1 + terza stringa sorgente revision2 file1 + quarta stringa sorgente revision2 file1 + quinta riga stringa revision2 file1 + sesta riga sorgente revision2 file1 + diff --git a/tests/e2e/fixtures/branches/translations/it/2_android.xml b/tests/e2e/fixtures/branches/translations/it/2_android.xml new file mode 100644 index 000000000..a69adf51e --- /dev/null +++ b/tests/e2e/fixtures/branches/translations/it/2_android.xml @@ -0,0 +1,9 @@ + + + la prima riga stringa revision2 file2 + seconda riga sorgente revision2 file2 + terza stringa sorgente revision2 file2 + quarta stringa sorgente revision2 file2 + quinta riga stringa revision2 file2 + sesta riga sorgente revision2 file2 + diff --git a/tests/e2e/fixtures/branches/translations/uk/1_android.xml b/tests/e2e/fixtures/branches/translations/uk/1_android.xml new file mode 100644 index 000000000..53ed9a7b6 --- /dev/null +++ b/tests/e2e/fixtures/branches/translations/uk/1_android.xml @@ -0,0 +1,9 @@ + + + джерело першої стрічки версія2 файл1 + джерело другої стрічки версія2 файл1 + джерело третьої стрічки версія2 файл1 + джерело четвертої стрічки версія2 файл1 + джерело п\'ятої стрічки версія2 файл1 + джерело шостої стрічки версія2 файл1 + diff --git a/tests/e2e/fixtures/branches/translations/uk/2_android.xml b/tests/e2e/fixtures/branches/translations/uk/2_android.xml new file mode 100644 index 000000000..8c47a4992 --- /dev/null +++ b/tests/e2e/fixtures/branches/translations/uk/2_android.xml @@ -0,0 +1,9 @@ + + + джерело першої стрічки версія2 файл2 + джерело другої стрічки версія2 файл2 + джерело третьої стрічки версія2 файл2 + джерело четвертої стрічки версія2 файл2 + джерело п\'ятої стрічки версія2 файл2 + джерело шостої стрічки версія2 файл2 + diff --git a/tests/e2e/fixtures/bundle/config/crowdin.yml b/tests/e2e/fixtures/bundle/config/crowdin.yml new file mode 100644 index 000000000..36fe96414 --- /dev/null +++ b/tests/e2e/fixtures/bundle/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/*.*" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/bundle/expected/it_all.string b/tests/e2e/fixtures/bundle/expected/it_all.string new file mode 100644 index 000000000..d319d5f76 --- /dev/null +++ b/tests/e2e/fixtures/bundle/expected/it_all.string @@ -0,0 +1,8 @@ +"str1" = "first string source file1"; +"str2" = "second string source file1"; +"str3" = "third string source file1"; +"str4" = "fourth string source file1"; +"str5" = "fifth string source file1"; +"apply" = "Apply"; +"cancel" = "Cancel"; +"reset" = "Rest"; diff --git a/tests/e2e/fixtures/bundle/expected/uk_all.string b/tests/e2e/fixtures/bundle/expected/uk_all.string new file mode 100644 index 000000000..d319d5f76 --- /dev/null +++ b/tests/e2e/fixtures/bundle/expected/uk_all.string @@ -0,0 +1,8 @@ +"str1" = "first string source file1"; +"str2" = "second string source file1"; +"str3" = "third string source file1"; +"str4" = "fourth string source file1"; +"str5" = "fifth string source file1"; +"apply" = "Apply"; +"cancel" = "Cancel"; +"reset" = "Rest"; diff --git a/tests/e2e/fixtures/bundle/files/sample.json b/tests/e2e/fixtures/bundle/files/sample.json new file mode 100644 index 000000000..c47f7bdf1 --- /dev/null +++ b/tests/e2e/fixtures/bundle/files/sample.json @@ -0,0 +1,11 @@ +{ + "apply": { + "message": "Apply" + }, + "cancel": { + "message": "Cancel" + }, + "reset": { + "message": "Rest" + } +} diff --git a/tests/e2e/fixtures/bundle/files/sample.xml b/tests/e2e/fixtures/bundle/files/sample.xml new file mode 100644 index 000000000..b69a70c02 --- /dev/null +++ b/tests/e2e/fixtures/bundle/files/sample.xml @@ -0,0 +1,8 @@ + + + first string source file1 + second string source file1 + third string source file1 + fourth string source file1 + fifth string source file1 + diff --git a/tests/e2e/fixtures/comment/config/crowdin.yml b/tests/e2e/fixtures/comment/config/crowdin.yml new file mode 100644 index 000000000..67a82500f --- /dev/null +++ b/tests/e2e/fixtures/comment/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/strings.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/comment/sources/strings.xml b/tests/e2e/fixtures/comment/sources/strings.xml new file mode 100644 index 000000000..c74dc1017 --- /dev/null +++ b/tests/e2e/fixtures/comment/sources/strings.xml @@ -0,0 +1,5 @@ + + + Welcome aboard + See you next time + diff --git a/tests/e2e/fixtures/config-file-options/alt-configs/labels-and-languages.yml b/tests/e2e/fixtures/config-file-options/alt-configs/labels-and-languages.yml new file mode 100644 index 000000000..14c4a77c3 --- /dev/null +++ b/tests/e2e/fixtures/config-file-options/alt-configs/labels-and-languages.yml @@ -0,0 +1,12 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +export_languages: + - "uk" +files: + - source: "/sources/labelled.json" + translation: "/translations/%two_letters_code%/%original_file_name%" + labels: + - "from-config" diff --git a/tests/e2e/fixtures/config-file-options/alt-configs/multilingual.yml b/tests/e2e/fixtures/config-file-options/alt-configs/multilingual.yml new file mode 100644 index 000000000..1b01b902e --- /dev/null +++ b/tests/e2e/fixtures/config-file-options/alt-configs/multilingual.yml @@ -0,0 +1,9 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/labelled.json" + translation: "/translations/all.json" + multilingual: true diff --git a/tests/e2e/fixtures/config-file-options/config/crowdin.yml b/tests/e2e/fixtures/config-file-options/config/crowdin.yml new file mode 100644 index 000000000..d9ef69364 --- /dev/null +++ b/tests/e2e/fixtures/config-file-options/config/crowdin.yml @@ -0,0 +1,21 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/messages.properties" + translation: "/translations/%two_letters_code%/%original_file_name%" + escape_quotes: 3 + escape_special_characters: 0 + - source: "/sources/script.js" + translation: "/translations/%two_letters_code%/%original_file_name%" + export_quotes: "double" + - source: "/sources/strings.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + type: "xml" + content_segmentation: false + translate_content: false + translate_attributes: false + translatable_elements: + - "/catalog/item" diff --git a/tests/e2e/fixtures/config-file-options/sources/labelled.json b/tests/e2e/fixtures/config-file-options/sources/labelled.json new file mode 100644 index 000000000..91e052255 --- /dev/null +++ b/tests/e2e/fixtures/config-file-options/sources/labelled.json @@ -0,0 +1,4 @@ +{ + "greeting": "Hello", + "farewell": "Bye" +} diff --git a/tests/e2e/fixtures/config-file-options/sources/messages.properties b/tests/e2e/fixtures/config-file-options/sources/messages.properties new file mode 100644 index 000000000..38657688e --- /dev/null +++ b/tests/e2e/fixtures/config-file-options/sources/messages.properties @@ -0,0 +1,2 @@ +greeting=Hello "world" +farewell=Bye diff --git a/tests/e2e/fixtures/config-file-options/sources/script.js b/tests/e2e/fixtures/config-file-options/sources/script.js new file mode 100644 index 000000000..7fc4d3763 --- /dev/null +++ b/tests/e2e/fixtures/config-file-options/sources/script.js @@ -0,0 +1,3 @@ +export default { + greeting: "Hello", +}; diff --git a/tests/e2e/fixtures/config-file-options/sources/strings.xml b/tests/e2e/fixtures/config-file-options/sources/strings.xml new file mode 100644 index 000000000..2947a2ecf --- /dev/null +++ b/tests/e2e/fixtures/config-file-options/sources/strings.xml @@ -0,0 +1,5 @@ + + + Hello. And a second sentence. + Goodbye. + diff --git a/tests/e2e/fixtures/config/alt-configs/bad-language-mapping.yml b/tests/e2e/fixtures/config/alt-configs/bad-language-mapping.yml new file mode 100644 index 000000000..903e4418b --- /dev/null +++ b/tests/e2e/fixtures/config/alt-configs/bad-language-mapping.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/main/**/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + languages_mapping: + two_letters_code: + not-a-language: xx diff --git a/tests/e2e/fixtures/config/alt-configs/good-language-mapping.yml b/tests/e2e/fixtures/config/alt-configs/good-language-mapping.yml new file mode 100644 index 000000000..9c066481f --- /dev/null +++ b/tests/e2e/fixtures/config/alt-configs/good-language-mapping.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/main/**/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + languages_mapping: + two_letters_code: + uk: ua diff --git a/tests/e2e/fixtures/config/alt-configs/no-source-match.yml b/tests/e2e/fixtures/config/alt-configs/no-source-match.yml new file mode 100644 index 000000000..145a4e119 --- /dev/null +++ b/tests/e2e/fixtures/config/alt-configs/no-source-match.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/nothing-here/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/config/config/crowdin.yml b/tests/e2e/fixtures/config/config/crowdin.yml new file mode 100644 index 000000000..96f1dc73c --- /dev/null +++ b/tests/e2e/fixtures/config/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/main/**/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + - source: "/sources/other/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/config/sources/main/app.xml b/tests/e2e/fixtures/config/sources/main/app.xml new file mode 100644 index 000000000..bbfa05a59 --- /dev/null +++ b/tests/e2e/fixtures/config/sources/main/app.xml @@ -0,0 +1,4 @@ + + + app string + diff --git a/tests/e2e/fixtures/config/sources/main/nested/deep.xml b/tests/e2e/fixtures/config/sources/main/nested/deep.xml new file mode 100644 index 000000000..9bd377594 --- /dev/null +++ b/tests/e2e/fixtures/config/sources/main/nested/deep.xml @@ -0,0 +1,4 @@ + + + deep string + diff --git a/tests/e2e/fixtures/config/sources/other/lib.xml b/tests/e2e/fixtures/config/sources/other/lib.xml new file mode 100644 index 000000000..dd5ed6641 --- /dev/null +++ b/tests/e2e/fixtures/config/sources/other/lib.xml @@ -0,0 +1,4 @@ + + + lib string + diff --git a/tests/e2e/fixtures/context/config/crowdin.yml b/tests/e2e/fixtures/context/config/crowdin.yml new file mode 100644 index 000000000..5cff7f40c --- /dev/null +++ b/tests/e2e/fixtures/context/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/context/sources/app.xml b/tests/e2e/fixtures/context/sources/app.xml new file mode 100644 index 000000000..dac2bd9fa --- /dev/null +++ b/tests/e2e/fixtures/context/sources/app.xml @@ -0,0 +1,5 @@ + + + Welcome aboard + Log out + diff --git a/tests/e2e/fixtures/context/sources/web.xml b/tests/e2e/fixtures/context/sources/web.xml new file mode 100644 index 000000000..ce7a3b234 --- /dev/null +++ b/tests/e2e/fixtures/context/sources/web.xml @@ -0,0 +1,4 @@ + + + Proceed to checkout + diff --git a/tests/e2e/fixtures/custom-language/config/crowdin.yml b/tests/e2e/fixtures/custom-language/config/crowdin.yml new file mode 100644 index 000000000..e37ce7dcc --- /dev/null +++ b/tests/e2e/fixtures/custom-language/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/custom-language/expected/dtk/1_android.xml b/tests/e2e/fixtures/custom-language/expected/dtk/1_android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/custom-language/expected/dtk/1_android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/custom-language/expected/dtk/2_android.xml b/tests/e2e/fixtures/custom-language/expected/dtk/2_android.xml new file mode 100644 index 000000000..7b80131ea --- /dev/null +++ b/tests/e2e/fixtures/custom-language/expected/dtk/2_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + seconda stringa + terza stringa + diff --git a/tests/e2e/fixtures/custom-language/expected/uk/1_android.xml b/tests/e2e/fixtures/custom-language/expected/uk/1_android.xml new file mode 100644 index 000000000..293507095 --- /dev/null +++ b/tests/e2e/fixtures/custom-language/expected/uk/1_android.xml @@ -0,0 +1,5 @@ + + + першоа стрічка + друга стрічка + diff --git a/tests/e2e/fixtures/custom-language/expected/uk/2_android.xml b/tests/e2e/fixtures/custom-language/expected/uk/2_android.xml new file mode 100644 index 000000000..314ae8ddc --- /dev/null +++ b/tests/e2e/fixtures/custom-language/expected/uk/2_android.xml @@ -0,0 +1,6 @@ + + + першоа стрічка + друга стрічка + third string + diff --git a/tests/e2e/fixtures/custom-language/sources/1_android.xml b/tests/e2e/fixtures/custom-language/sources/1_android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/custom-language/sources/1_android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/custom-language/sources/2_android.xml b/tests/e2e/fixtures/custom-language/sources/2_android.xml new file mode 100644 index 000000000..3db26f896 --- /dev/null +++ b/tests/e2e/fixtures/custom-language/sources/2_android.xml @@ -0,0 +1,6 @@ + + + first string + second string + third string + diff --git a/tests/e2e/fixtures/custom-language/translations/dtk/1_android.xml b/tests/e2e/fixtures/custom-language/translations/dtk/1_android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/custom-language/translations/dtk/1_android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/custom-language/translations/dtk/2_android.xml b/tests/e2e/fixtures/custom-language/translations/dtk/2_android.xml new file mode 100644 index 000000000..7b80131ea --- /dev/null +++ b/tests/e2e/fixtures/custom-language/translations/dtk/2_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + seconda stringa + terza stringa + diff --git a/tests/e2e/fixtures/custom-language/translations/uk/1_android.xml b/tests/e2e/fixtures/custom-language/translations/uk/1_android.xml new file mode 100644 index 000000000..293507095 --- /dev/null +++ b/tests/e2e/fixtures/custom-language/translations/uk/1_android.xml @@ -0,0 +1,5 @@ + + + першоа стрічка + друга стрічка + diff --git a/tests/e2e/fixtures/custom-language/translations/uk/2_android.xml b/tests/e2e/fixtures/custom-language/translations/uk/2_android.xml new file mode 100644 index 000000000..293507095 --- /dev/null +++ b/tests/e2e/fixtures/custom-language/translations/uk/2_android.xml @@ -0,0 +1,5 @@ + + + першоа стрічка + друга стрічка + diff --git a/tests/e2e/fixtures/custom-segmentation/alt-configs/crowdin-rev2.yml b/tests/e2e/fixtures/custom-segmentation/alt-configs/crowdin-rev2.yml new file mode 100644 index 000000000..5846d0fb5 --- /dev/null +++ b/tests/e2e/fixtures/custom-segmentation/alt-configs/crowdin-rev2.yml @@ -0,0 +1,12 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +files: + - source: "/sources/**/*" + custom_segmentation: "/rules/sample.srx.xml" + dest: "/Folder/%original_file_name%" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/custom-segmentation/config/crowdin.yml b/tests/e2e/fixtures/custom-segmentation/config/crowdin.yml new file mode 100644 index 000000000..76abfc2c6 --- /dev/null +++ b/tests/e2e/fixtures/custom-segmentation/config/crowdin.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +files: + - source: "/sources/**/*" + custom_segmentation: "/rules/sample.srx.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/custom-segmentation/rules/invalid-regexp.srx.xml b/tests/e2e/fixtures/custom-segmentation/rules/invalid-regexp.srx.xml new file mode 100755 index 000000000..3515ec73b --- /dev/null +++ b/tests/e2e/fixtures/custom-segmentation/rules/invalid-regexp.srx.xml @@ -0,0 +1,45 @@ + + +
+ + + +
+ + + + + + ^\s*[0-9]+\. + \s + + + \n + + + [\.\?!]+ + \s + + + + + + \sMr\. + \s + + + \sU\.K\. + \s + + + + + + + + + +
\ No newline at end of file diff --git a/tests/e2e/fixtures/custom-segmentation/rules/invalid.srx.xml b/tests/e2e/fixtures/custom-segmentation/rules/invalid.srx.xml new file mode 100755 index 000000000..b73c0580b --- /dev/null +++ b/tests/e2e/fixtures/custom-segmentation/rules/invalid.srx.xml @@ -0,0 +1,45 @@ + + +
+ + + +
+ + + + + + ^\s*[0-9]+\. + \s + + + \n + + + [\.\?!]+ + \s + + + + + + \sMr\. + \s + + + \sU\.K\. + \s + + + + + + + + +
\ No newline at end of file diff --git a/tests/e2e/fixtures/custom-segmentation/rules/sampleV2.srx.xml b/tests/e2e/fixtures/custom-segmentation/rules/sampleV2.srx.xml new file mode 100755 index 000000000..0a2a4f0dc --- /dev/null +++ b/tests/e2e/fixtures/custom-segmentation/rules/sampleV2.srx.xml @@ -0,0 +1,41 @@ + + +
+ + + +
+ + + + + + ^\s*[0-9]+\. + \s + + + \n + + + [\.\?!]+ + \s + + + + + + \sMr\. + \s + + + + + + + + + +
\ No newline at end of file diff --git a/tests/e2e/fixtures/custom-segmentation/rules/valid.srx.xml b/tests/e2e/fixtures/custom-segmentation/rules/valid.srx.xml new file mode 100755 index 000000000..32052d80b --- /dev/null +++ b/tests/e2e/fixtures/custom-segmentation/rules/valid.srx.xml @@ -0,0 +1,45 @@ + + +
+ + + +
+ + + + + + ^\s*[0-9]+\. + \s + + + \n + + + [\.\?!]+ + \s + + + + + + \sMr\. + \s + + + \sU\.K\. + \s + + + + + + + + + +
\ No newline at end of file diff --git a/tests/e2e/fixtures/custom-segmentation/sources/sample.docx b/tests/e2e/fixtures/custom-segmentation/sources/sample.docx new file mode 100644 index 000000000..344719c90 Binary files /dev/null and b/tests/e2e/fixtures/custom-segmentation/sources/sample.docx differ diff --git a/tests/e2e/fixtures/custom-segmentation/sources/strings.xml b/tests/e2e/fixtures/custom-segmentation/sources/strings.xml new file mode 100644 index 000000000..c0b33d941 --- /dev/null +++ b/tests/e2e/fixtures/custom-segmentation/sources/strings.xml @@ -0,0 +1,12 @@ + + Home + Camera + Gallery + Home + Slideshow + Tools + Crop + Clear + Refresh page + Help center + \ No newline at end of file diff --git a/tests/e2e/fixtures/delete-obsolete/alt-configs/crowdin-rev4.yml b/tests/e2e/fixtures/delete-obsolete/alt-configs/crowdin-rev4.yml new file mode 100644 index 000000000..d5cb7503a --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/alt-configs/crowdin-rev4.yml @@ -0,0 +1,9 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/**/*" + translation: "/translations/%two_letters_code%/%original_file_name%" + dest: "/destination/%original_file_name%" diff --git a/tests/e2e/fixtures/delete-obsolete/alt-configs/crowdin-rev5.yml b/tests/e2e/fixtures/delete-obsolete/alt-configs/crowdin-rev5.yml new file mode 100644 index 000000000..29240b818 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/alt-configs/crowdin-rev5.yml @@ -0,0 +1,12 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/1_android.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + dest: "/other_destination/%original_file_name%" + - source: "/2_android.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + dest: "/other_destination/%original_file_name%" diff --git a/tests/e2e/fixtures/delete-obsolete/config/crowdin.yml b/tests/e2e/fixtures/delete-obsolete/config/crowdin.yml new file mode 100644 index 000000000..f63191542 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/config/crowdin.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/**/*" + translation: "/translations/%two_letters_code%/%original_file_name%" + ignore: + - "/**/*.csv" + - source: "/*.csv" + dest: "/destination/%original_file_name%" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/delete-obsolete/sources/1_android.xml b/tests/e2e/fixtures/delete-obsolete/sources/1_android.xml new file mode 100644 index 000000000..d72a2d14f --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources/1_android.xml @@ -0,0 +1,9 @@ + + + first string source file1 + second string source file1 + third string source file1 + fourth string source file1 + fifth string source file1 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources/1_simple.csv b/tests/e2e/fixtures/delete-obsolete/sources/1_simple.csv new file mode 100755 index 000000000..2b84a1e76 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources/1_simple.csv @@ -0,0 +1,2 @@ +ident,source,context,max_length,translation +ident1,file 1 string 1,context 1,20,file 1 string 1 diff --git a/tests/e2e/fixtures/delete-obsolete/sources/2_android.xml b/tests/e2e/fixtures/delete-obsolete/sources/2_android.xml new file mode 100644 index 000000000..f64276c64 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources/2_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources/3_android.xml b/tests/e2e/fixtures/delete-obsolete/sources/3_android.xml new file mode 100644 index 000000000..f64276c64 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources/3_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources/lang/4_android.xml b/tests/e2e/fixtures/delete-obsolete/sources/lang/4_android.xml new file mode 100644 index 000000000..f64276c64 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources/lang/4_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources/lang/en-US.json b/tests/e2e/fixtures/delete-obsolete/sources/lang/en-US.json new file mode 100644 index 000000000..c47f7bdf1 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources/lang/en-US.json @@ -0,0 +1,11 @@ +{ + "apply": { + "message": "Apply" + }, + "cancel": { + "message": "Cancel" + }, + "reset": { + "message": "Rest" + } +} diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev2/1_android.xml b/tests/e2e/fixtures/delete-obsolete/sources_rev2/1_android.xml new file mode 100644 index 000000000..1990a5d05 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev2/1_android.xml @@ -0,0 +1,10 @@ + + + first string source revision2 file1 + second string source revision2 file1 + third string source revision2 file1 + fourth string source revision2 file1 + fifth string source revision2 file1 + sixth string source revision2 file1 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev2/3_android.xml b/tests/e2e/fixtures/delete-obsolete/sources_rev2/3_android.xml new file mode 100644 index 000000000..f64276c64 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev2/3_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev2/lang/4_android.xml b/tests/e2e/fixtures/delete-obsolete/sources_rev2/lang/4_android.xml new file mode 100644 index 000000000..f64276c64 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev2/lang/4_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev2/lang/en-US.json b/tests/e2e/fixtures/delete-obsolete/sources_rev2/lang/en-US.json new file mode 100644 index 000000000..c47f7bdf1 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev2/lang/en-US.json @@ -0,0 +1,11 @@ +{ + "apply": { + "message": "Apply" + }, + "cancel": { + "message": "Cancel" + }, + "reset": { + "message": "Rest" + } +} diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev3/1_android.xml b/tests/e2e/fixtures/delete-obsolete/sources_rev3/1_android.xml new file mode 100644 index 000000000..1990a5d05 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev3/1_android.xml @@ -0,0 +1,10 @@ + + + first string source revision2 file1 + second string source revision2 file1 + third string source revision2 file1 + fourth string source revision2 file1 + fifth string source revision2 file1 + sixth string source revision2 file1 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev3/1_simple.csv b/tests/e2e/fixtures/delete-obsolete/sources_rev3/1_simple.csv new file mode 100755 index 000000000..2b84a1e76 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev3/1_simple.csv @@ -0,0 +1,2 @@ +ident,source,context,max_length,translation +ident1,file 1 string 1,context 1,20,file 1 string 1 diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev4/1_android.xml b/tests/e2e/fixtures/delete-obsolete/sources_rev4/1_android.xml new file mode 100644 index 000000000..1990a5d05 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev4/1_android.xml @@ -0,0 +1,10 @@ + + + first string source revision2 file1 + second string source revision2 file1 + third string source revision2 file1 + fourth string source revision2 file1 + fifth string source revision2 file1 + sixth string source revision2 file1 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev5/1_android.xml b/tests/e2e/fixtures/delete-obsolete/sources_rev5/1_android.xml new file mode 100644 index 000000000..1990a5d05 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev5/1_android.xml @@ -0,0 +1,10 @@ + + + first string source revision2 file1 + second string source revision2 file1 + third string source revision2 file1 + fourth string source revision2 file1 + fifth string source revision2 file1 + sixth string source revision2 file1 + + diff --git a/tests/e2e/fixtures/delete-obsolete/sources_rev5/2_android.xml b/tests/e2e/fixtures/delete-obsolete/sources_rev5/2_android.xml new file mode 100644 index 000000000..f64276c64 --- /dev/null +++ b/tests/e2e/fixtures/delete-obsolete/sources_rev5/2_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/dest/alt-configs/crowdin-invalid.yml b/tests/e2e/fixtures/dest/alt-configs/crowdin-invalid.yml new file mode 100644 index 000000000..73aad9bb2 --- /dev/null +++ b/tests/e2e/fixtures/dest/alt-configs/crowdin-invalid.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/*.xml" + translation: "/android_%locale_with_underscore%.xml" + dest: "/Android.xml" diff --git a/tests/e2e/fixtures/dest/android.xml b/tests/e2e/fixtures/dest/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/android_it_IT.xml b/tests/e2e/fixtures/dest/android_it_IT.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/android_it_IT.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/android_uk_UA.xml b/tests/e2e/fixtures/dest/android_uk_UA.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/android_uk_UA.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/config/crowdin.yml b/tests/e2e/fixtures/dest/config/crowdin.yml new file mode 100644 index 000000000..153ae3009 --- /dev/null +++ b/tests/e2e/fixtures/dest/config/crowdin.yml @@ -0,0 +1,25 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +files: + - source: "/android.xml" + translation: "/android_%locale_with_underscore%.xml" + dest: "/Android.xml" + - source: "/folder/android_en_US.xml" + translation: "/folder/android_%locale_with_underscore%.xml" + dest: "/Folder/Android.xml" + - source: "/folder/client_en_US.xml" + translation: "/folder/client_%locale_with_underscore%.xml" + dest: "Folder/Client.xml" + - source: "/destCheckFolder/android.xml" + translation: "/destCheckFolder/android_%locale_with_underscore%.xml" + dest: "/Test-%original_path%/%file_extension%/%file_name%/%original_file_name%" + - source: "/destCheckFolderParallelFileProcess/android.xml" + translation: "/destCheckFolderParallelFileProcess/android_%locale_with_underscore%.xml" + dest: "/Test-%original_path%/%file_extension%/%original_file_name%" + - source: "/destCheckFolderParallelFileProcess/second_android.xml" + translation: "/destCheckFolderParallelFileProcess/second_android_%locale_with_underscore%.xml" + dest: "/Test-%original_path%/%file_extension%/%original_file_name%" diff --git a/tests/e2e/fixtures/dest/destCheckFolder/android.xml b/tests/e2e/fixtures/dest/destCheckFolder/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolder/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/destCheckFolder/android_it_IT.xml b/tests/e2e/fixtures/dest/destCheckFolder/android_it_IT.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolder/android_it_IT.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/destCheckFolder/android_uk_UA.xml b/tests/e2e/fixtures/dest/destCheckFolder/android_uk_UA.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolder/android_uk_UA.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android.xml b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android_it_IT.xml b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android_it_IT.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android_it_IT.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android_uk_UA.xml b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android_uk_UA.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/android_uk_UA.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android.xml b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android.xml new file mode 100644 index 000000000..5747879d5 --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android.xml @@ -0,0 +1,5 @@ + + + Second file first string + Second file second string + diff --git a/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android_it_IT.xml b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android_it_IT.xml new file mode 100644 index 000000000..1ae45da98 --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android_it_IT.xml @@ -0,0 +1,5 @@ + + + second file first string + second file second string + diff --git a/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android_uk_UA.xml b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android_uk_UA.xml new file mode 100644 index 000000000..1ae45da98 --- /dev/null +++ b/tests/e2e/fixtures/dest/destCheckFolderParallelFileProcess/second_android_uk_UA.xml @@ -0,0 +1,5 @@ + + + second file first string + second file second string + diff --git a/tests/e2e/fixtures/dest/folder/android_en_US.xml b/tests/e2e/fixtures/dest/folder/android_en_US.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/folder/android_en_US.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/folder/android_it_IT.xml b/tests/e2e/fixtures/dest/folder/android_it_IT.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/folder/android_it_IT.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/folder/android_uk_UA.xml b/tests/e2e/fixtures/dest/folder/android_uk_UA.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/folder/android_uk_UA.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/folder/client_en_US.xml b/tests/e2e/fixtures/dest/folder/client_en_US.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/folder/client_en_US.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/folder/client_it_IT.xml b/tests/e2e/fixtures/dest/folder/client_it_IT.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/folder/client_it_IT.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/dest/folder/client_uk_UA.xml b/tests/e2e/fixtures/dest/folder/client_uk_UA.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/dest/folder/client_uk_UA.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/distribution/config/crowdin.yml b/tests/e2e/fixtures/distribution/config/crowdin.yml new file mode 100644 index 000000000..d6cd2475c --- /dev/null +++ b/tests/e2e/fixtures/distribution/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/distribution/sources/1_android.xml b/tests/e2e/fixtures/distribution/sources/1_android.xml new file mode 100644 index 000000000..9f4263e02 --- /dev/null +++ b/tests/e2e/fixtures/distribution/sources/1_android.xml @@ -0,0 +1,5 @@ + + + first string source file1 + second string source file1 + diff --git a/tests/e2e/fixtures/distribution/translations/uk/1_android.xml b/tests/e2e/fixtures/distribution/translations/uk/1_android.xml new file mode 100644 index 000000000..bf47916e2 --- /dev/null +++ b/tests/e2e/fixtures/distribution/translations/uk/1_android.xml @@ -0,0 +1,5 @@ + + + перший рядок файлу1 + другий рядок файлу1 + diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/all-params.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/all-params.yml new file mode 100644 index 000000000..cfbbe184a --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/all-params.yml @@ -0,0 +1,16 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + length_correction: 90 + prefix: "p::" + suffix: "::s" + character_transformation: cyrillic diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/arabic.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/arabic.yml new file mode 100644 index 000000000..5e8f5f02b --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/arabic.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + character_transformation: arabic diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/asian.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/asian.yml new file mode 100644 index 000000000..0b4065c50 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/asian.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + character_transformation: asian diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/cyrillic.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/cyrillic.yml new file mode 100644 index 000000000..f6f4dd358 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/cyrillic.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + character_transformation: "cyrillic" diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/european.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/european.yml new file mode 100644 index 000000000..f53a1ccd3 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/european.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + character_transformation: european diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/export-only-approved.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/export-only-approved.yml new file mode 100644 index 000000000..8949a8792 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/export-only-approved.yml @@ -0,0 +1,14 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + export_only_approved: true + +pseudo_localization: + character_transformation: "cyrillic" diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-enum.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-enum.yml new file mode 100644 index 000000000..0c7025008 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-enum.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + character_transformation: not-exists diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-length-out-of-range.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-length-out-of-range.yml new file mode 100644 index 000000000..04edffe18 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-length-out-of-range.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + length_correction: 102 diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-prefix-type.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-prefix-type.yml new file mode 100644 index 000000000..6439d63b7 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/invalid-prefix-type.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + prefix: 1 diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/length-correction.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/length-correction.yml new file mode 100644 index 000000000..5f74a4f4c --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/length-correction.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + length_correction: -50 diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/no-pseudo-section.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/no-pseudo-section.yml new file mode 100644 index 000000000..e37ce7dcc --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/no-pseudo-section.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/prefix.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/prefix.yml new file mode 100644 index 000000000..50f41b387 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/prefix.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + prefix: "prefix>" diff --git a/tests/e2e/fixtures/download-pseudo/alt-configs/suffix.yml b/tests/e2e/fixtures/download-pseudo/alt-configs/suffix.yml new file mode 100644 index 000000000..f0de0058c --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/alt-configs/suffix.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: + suffix: ">suffix" diff --git a/tests/e2e/fixtures/download-pseudo/config/crowdin.yml b/tests/e2e/fixtures/download-pseudo/config/crowdin.yml new file mode 100644 index 000000000..4d1ee0376 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/config/crowdin.yml @@ -0,0 +1,12 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + +pseudo_localization: {} diff --git a/tests/e2e/fixtures/download-pseudo/expected/all_params/android.xml b/tests/e2e/fixtures/download-pseudo/expected/all_params/android.xml new file mode 100644 index 000000000..f2687cdec --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/expected/all_params/android.xml @@ -0,0 +1,6 @@ + + + p::фііррсстт ссттррііндждж::s + p::сеессоонндд ссттррінндждж::s + p::тххііррдд ссттррііндждж::s + diff --git a/tests/e2e/fixtures/download-pseudo/expected/arabic/android.xml b/tests/e2e/fixtures/download-pseudo/expected/arabic/android.xml new file mode 100644 index 000000000..139feed19 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/expected/arabic/android.xml @@ -0,0 +1,6 @@ + + + بهقسف سفقهىل + سثؤخىي سفقهىل + فاهقي سفقهىل + diff --git a/tests/e2e/fixtures/download-pseudo/expected/asian/android.xml b/tests/e2e/fixtures/download-pseudo/expected/asian/android.xml new file mode 100644 index 000000000..0aae77917 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/expected/asian/android.xml @@ -0,0 +1,6 @@ + + + 弗艾儿丝提 丝提儿艾娜吉 + 丝伊西哦娜迪 丝提儿艾娜吉 + 提尺艾儿迪 丝提儿艾娜吉 + diff --git a/tests/e2e/fixtures/download-pseudo/expected/default/android.xml b/tests/e2e/fixtures/download-pseudo/expected/default/android.xml new file mode 100644 index 000000000..3db26f896 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/expected/default/android.xml @@ -0,0 +1,6 @@ + + + first string + second string + third string + diff --git a/tests/e2e/fixtures/download-pseudo/expected/european/android.xml b/tests/e2e/fixtures/download-pseudo/expected/european/android.xml new file mode 100644 index 000000000..90a1801b0 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/expected/european/android.xml @@ -0,0 +1,6 @@ + + + ƒîŕšţ šţŕîñĝ + šéçôñð šţŕîñĝ + ţĥîŕð šţŕîñĝ + diff --git a/tests/e2e/fixtures/download-pseudo/expected/length_correction/android.xml b/tests/e2e/fixtures/download-pseudo/expected/length_correction/android.xml new file mode 100644 index 000000000..0007703da --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/expected/length_correction/android.xml @@ -0,0 +1,6 @@ + + + frtsrn + scn tig + tidsrn + diff --git a/tests/e2e/fixtures/download-pseudo/expected/prefix/android.xml b/tests/e2e/fixtures/download-pseudo/expected/prefix/android.xml new file mode 100644 index 000000000..7c581f30f --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/expected/prefix/android.xml @@ -0,0 +1,6 @@ + + + prefix>first string + prefix>second string + prefix>third string + diff --git a/tests/e2e/fixtures/download-pseudo/expected/suffix/android.xml b/tests/e2e/fixtures/download-pseudo/expected/suffix/android.xml new file mode 100644 index 000000000..7afbe26b5 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/expected/suffix/android.xml @@ -0,0 +1,6 @@ + + + first string>suffix + second string>suffix + third string>suffix + diff --git a/tests/e2e/fixtures/download-pseudo/sources/android.xml b/tests/e2e/fixtures/download-pseudo/sources/android.xml new file mode 100644 index 000000000..53a60de70 --- /dev/null +++ b/tests/e2e/fixtures/download-pseudo/sources/android.xml @@ -0,0 +1,7 @@ + + + first string + second string + third string + + diff --git a/tests/e2e/fixtures/download-sources/alt-configs/flat-hierarchy.yml b/tests/e2e/fixtures/download-sources/alt-configs/flat-hierarchy.yml new file mode 100644 index 000000000..576615eb7 --- /dev/null +++ b/tests/e2e/fixtures/download-sources/alt-configs/flat-hierarchy.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/folder_2/*.xml" + translation: "/%locale%/folder_2/%file_name%.xml" diff --git a/tests/e2e/fixtures/download-sources/alt-configs/no-sources.yml b/tests/e2e/fixtures/download-sources/alt-configs/no-sources.yml new file mode 100644 index 000000000..f4282f784 --- /dev/null +++ b/tests/e2e/fixtures/download-sources/alt-configs/no-sources.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/folder_not_exists/**/*.xml" + translation: "/%locale%/folder_1/**/%file_name%.xml" diff --git a/tests/e2e/fixtures/download-sources/config/crowdin.yml b/tests/e2e/fixtures/download-sources/config/crowdin.yml new file mode 100644 index 000000000..e74cabeed --- /dev/null +++ b/tests/e2e/fixtures/download-sources/config/crowdin.yml @@ -0,0 +1,15 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/folder_1/**/*.xml" + dest: "/root/%original_path%/%file_name%.xml" + translation: "/%locale%/folder_1/**/%file_name%.xml" + - source: "/folder_2/android_1.xml" + translation: "/%locale%/folder_2/%file_name%.xml" + - source: "/folder_2/android_[2-3].xml" + translation: "/%locale%/folder_2/%file_name%.xml" + - source: "/folder_2/android_4?.xml" + translation: "/%locale%/folder_2/%file_name%.xml" diff --git a/tests/e2e/fixtures/download-sources/folder_1/android.xml b/tests/e2e/fixtures/download-sources/folder_1/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-sources/folder_1/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-sources/folder_1/f1/android.xml b/tests/e2e/fixtures/download-sources/folder_1/f1/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-sources/folder_1/f1/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-sources/folder_1/f1/f2/android.xml b/tests/e2e/fixtures/download-sources/folder_1/f1/f2/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-sources/folder_1/f1/f2/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-sources/folder_2/android_1.xml b/tests/e2e/fixtures/download-sources/folder_2/android_1.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-sources/folder_2/android_1.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-sources/folder_2/android_2.xml b/tests/e2e/fixtures/download-sources/folder_2/android_2.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-sources/folder_2/android_2.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-sources/folder_2/android_3.xml b/tests/e2e/fixtures/download-sources/folder_2/android_3.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-sources/folder_2/android_3.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-sources/folder_2/android_4a.xml b/tests/e2e/fixtures/download-sources/folder_2/android_4a.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-sources/folder_2/android_4a.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-sources/folder_2/f1/android.xml b/tests/e2e/fixtures/download-sources/folder_2/f1/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-sources/folder_2/f1/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-translations-all/config/crowdin.yml b/tests/e2e/fixtures/download-translations-all/config/crowdin.yml new file mode 100644 index 000000000..d17eca0de --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/root/**/*.xml" + translation: "/translations/%two_letters_code%/**/%file_name%.xml" diff --git a/tests/e2e/fixtures/download-translations-all/expected/translations/it/android.xml b/tests/e2e/fixtures/download-translations-all/expected/translations/it/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/expected/translations/it/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-translations-all/expected/translations/it/folder/android.xml b/tests/e2e/fixtures/download-translations-all/expected/translations/it/folder/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/expected/translations/it/folder/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-translations-all/expected/translations/it/{{cookiecutter.module_name}}/android.xml b/tests/e2e/fixtures/download-translations-all/expected/translations/it/{{cookiecutter.module_name}}/android.xml new file mode 100644 index 000000000..98a996698 --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/expected/translations/it/{{cookiecutter.module_name}}/android.xml @@ -0,0 +1,6 @@ + + + first string + second string + + diff --git a/tests/e2e/fixtures/download-translations-all/expected/translations/uk/android.xml b/tests/e2e/fixtures/download-translations-all/expected/translations/uk/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/expected/translations/uk/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-translations-all/expected/translations/uk/folder/android.xml b/tests/e2e/fixtures/download-translations-all/expected/translations/uk/folder/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/expected/translations/uk/folder/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/download-translations-all/expected/translations/uk/{{cookiecutter.module_name}}/android.xml b/tests/e2e/fixtures/download-translations-all/expected/translations/uk/{{cookiecutter.module_name}}/android.xml new file mode 100644 index 000000000..98a996698 --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/expected/translations/uk/{{cookiecutter.module_name}}/android.xml @@ -0,0 +1,6 @@ + + + first string + second string + + diff --git a/tests/e2e/fixtures/download-translations-all/files/root/android.xml b/tests/e2e/fixtures/download-translations-all/files/root/android.xml new file mode 100644 index 000000000..98a996698 --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/files/root/android.xml @@ -0,0 +1,6 @@ + + + first string + second string + + diff --git a/tests/e2e/fixtures/download-translations-all/files/root/folder/android.xml b/tests/e2e/fixtures/download-translations-all/files/root/folder/android.xml new file mode 100644 index 000000000..98a996698 --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/files/root/folder/android.xml @@ -0,0 +1,6 @@ + + + first string + second string + + diff --git a/tests/e2e/fixtures/download-translations-all/files/root/{{cookiecutter.module_name}}/android.xml b/tests/e2e/fixtures/download-translations-all/files/root/{{cookiecutter.module_name}}/android.xml new file mode 100644 index 000000000..98a996698 --- /dev/null +++ b/tests/e2e/fixtures/download-translations-all/files/root/{{cookiecutter.module_name}}/android.xml @@ -0,0 +1,6 @@ + + + first string + second string + + diff --git a/tests/e2e/fixtures/env-variables/config/crowdin.yml b/tests/e2e/fixtures/env-variables/config/crowdin.yml new file mode 100644 index 000000000..2c5929114 --- /dev/null +++ b/tests/e2e/fixtures/env-variables/config/crowdin.yml @@ -0,0 +1,9 @@ +project_id_env: "TEST_PROJECT_ID_ENV" +api_token_env: "TEST_API_TOKEN_ENV" +base_path_env: "TEST_BASE_PATH_ENV" +base_url_env: "TEST_BASE_URL_ENV" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/env-variables/expected/it/1_android.xml b/tests/e2e/fixtures/env-variables/expected/it/1_android.xml new file mode 100644 index 000000000..a11b5c0ed --- /dev/null +++ b/tests/e2e/fixtures/env-variables/expected/it/1_android.xml @@ -0,0 +1,8 @@ + + + la prima riga stringa revision2 file1 + seconda riga sorgente revision2 file1 + terza stringa sorgente revision2 file1 + quarta stringa sorgente revision2 file1 + quinta riga stringa revision2 file1 + diff --git a/tests/e2e/fixtures/env-variables/expected/it/2_android.xml b/tests/e2e/fixtures/env-variables/expected/it/2_android.xml new file mode 100644 index 000000000..a69adf51e --- /dev/null +++ b/tests/e2e/fixtures/env-variables/expected/it/2_android.xml @@ -0,0 +1,9 @@ + + + la prima riga stringa revision2 file2 + seconda riga sorgente revision2 file2 + terza stringa sorgente revision2 file2 + quarta stringa sorgente revision2 file2 + quinta riga stringa revision2 file2 + sesta riga sorgente revision2 file2 + diff --git a/tests/e2e/fixtures/env-variables/expected/uk/1_android.xml b/tests/e2e/fixtures/env-variables/expected/uk/1_android.xml new file mode 100644 index 000000000..b3c4517eb --- /dev/null +++ b/tests/e2e/fixtures/env-variables/expected/uk/1_android.xml @@ -0,0 +1,8 @@ + + + джерело першої стрічки версія2 файл1 + джерело другої стрічки версія2 файл1 + джерело третьої стрічки версія2 файл1 + джерело четвертої стрічки версія2 файл1 + джерело п\'ятої стрічки версія2 файл1 + diff --git a/tests/e2e/fixtures/env-variables/expected/uk/2_android.xml b/tests/e2e/fixtures/env-variables/expected/uk/2_android.xml new file mode 100644 index 000000000..8c47a4992 --- /dev/null +++ b/tests/e2e/fixtures/env-variables/expected/uk/2_android.xml @@ -0,0 +1,9 @@ + + + джерело першої стрічки версія2 файл2 + джерело другої стрічки версія2 файл2 + джерело третьої стрічки версія2 файл2 + джерело четвертої стрічки версія2 файл2 + джерело п\'ятої стрічки версія2 файл2 + джерело шостої стрічки версія2 файл2 + diff --git a/tests/e2e/fixtures/env-variables/sources/1_android.xml b/tests/e2e/fixtures/env-variables/sources/1_android.xml new file mode 100644 index 000000000..a0cd0aed4 --- /dev/null +++ b/tests/e2e/fixtures/env-variables/sources/1_android.xml @@ -0,0 +1,9 @@ + + + first string source file1 + second string source file1 + third string source file1 + fourth string source file1 + fifth string source file1 + + diff --git a/tests/e2e/fixtures/env-variables/sources/2_android.xml b/tests/e2e/fixtures/env-variables/sources/2_android.xml new file mode 100644 index 000000000..cb2c0ec5a --- /dev/null +++ b/tests/e2e/fixtures/env-variables/sources/2_android.xml @@ -0,0 +1,10 @@ + + + first string source file2 + second string source file2 + third string source file2 + fourth string source file2 + fifth string source file2 + sixth string source file2 + + diff --git a/tests/e2e/fixtures/env-variables/translations/it/1_android.xml b/tests/e2e/fixtures/env-variables/translations/it/1_android.xml new file mode 100644 index 000000000..a93ccb028 --- /dev/null +++ b/tests/e2e/fixtures/env-variables/translations/it/1_android.xml @@ -0,0 +1,10 @@ + + + la prima riga stringa revision2 file1 + seconda riga sorgente revision2 file1 + terza stringa sorgente revision2 file1 + quarta stringa sorgente revision2 file1 + quinta riga stringa revision2 file1 + sesta riga sorgente revision2 file1 + + diff --git a/tests/e2e/fixtures/env-variables/translations/it/2_android.xml b/tests/e2e/fixtures/env-variables/translations/it/2_android.xml new file mode 100644 index 000000000..b8786c9f5 --- /dev/null +++ b/tests/e2e/fixtures/env-variables/translations/it/2_android.xml @@ -0,0 +1,10 @@ + + + la prima riga stringa revision2 file2 + seconda riga sorgente revision2 file2 + terza stringa sorgente revision2 file2 + quarta stringa sorgente revision2 file2 + quinta riga stringa revision2 file2 + sesta riga sorgente revision2 file2 + + diff --git a/tests/e2e/fixtures/env-variables/translations/uk/1_android.xml b/tests/e2e/fixtures/env-variables/translations/uk/1_android.xml new file mode 100644 index 000000000..53744d414 --- /dev/null +++ b/tests/e2e/fixtures/env-variables/translations/uk/1_android.xml @@ -0,0 +1,10 @@ + + + джерело першої стрічки версія2 файл1 + джерело другої стрічки версія2 файл1 + джерело третьої стрічки версія2 файл1 + джерело четвертої стрічки версія2 файл1 + джерело п'ятої стрічки версія2 файл1 + джерело шостої стрічки версія2 файл1 + + diff --git a/tests/e2e/fixtures/env-variables/translations/uk/2_android.xml b/tests/e2e/fixtures/env-variables/translations/uk/2_android.xml new file mode 100644 index 000000000..90d66894f --- /dev/null +++ b/tests/e2e/fixtures/env-variables/translations/uk/2_android.xml @@ -0,0 +1,10 @@ + + + джерело першої стрічки версія2 файл2 + джерело другої стрічки версія2 файл2 + джерело третьої стрічки версія2 файл2 + джерело четвертої стрічки версія2 файл2 + джерело п'ятої стрічки версія2 файл2 + джерело шостої стрічки версія2 файл2 + + diff --git a/tests/e2e/fixtures/excluded-languages/alt-configs/crowdin-excluded-languages.yml b/tests/e2e/fixtures/excluded-languages/alt-configs/crowdin-excluded-languages.yml new file mode 100644 index 000000000..eff0dee57 --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/alt-configs/crowdin-excluded-languages.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + excluded_target_languages: ["it"] diff --git a/tests/e2e/fixtures/excluded-languages/alt-configs/crowdin-two-groups.yml b/tests/e2e/fixtures/excluded-languages/alt-configs/crowdin-two-groups.yml new file mode 100644 index 000000000..1169102c1 --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/alt-configs/crowdin-two-groups.yml @@ -0,0 +1,14 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/1_android.xml" + translation: "/translations/%two_letters_code%/1_android.xml" + excluded_target_languages: ["it"] + - source: "/sources/2_android.xml" + translation: "/translations/%two_letters_code%/2_android.xml" + excluded_target_languages: ["uk"] diff --git a/tests/e2e/fixtures/excluded-languages/config/crowdin.yml b/tests/e2e/fixtures/excluded-languages/config/crowdin.yml new file mode 100644 index 000000000..e37ce7dcc --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/excluded-languages/expected/it/1_android.xml b/tests/e2e/fixtures/excluded-languages/expected/it/1_android.xml new file mode 100644 index 000000000..3e057d5ee --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/expected/it/1_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + seconda stringa + third string + diff --git a/tests/e2e/fixtures/excluded-languages/expected/it/2_android.xml b/tests/e2e/fixtures/excluded-languages/expected/it/2_android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/expected/it/2_android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/excluded-languages/expected/uk/1_android.xml b/tests/e2e/fixtures/excluded-languages/expected/uk/1_android.xml new file mode 100644 index 000000000..8361b600c --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/expected/uk/1_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + third string + diff --git a/tests/e2e/fixtures/excluded-languages/expected/uk/2_android.xml b/tests/e2e/fixtures/excluded-languages/expected/uk/2_android.xml new file mode 100644 index 000000000..9084e167a --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/expected/uk/2_android.xml @@ -0,0 +1,5 @@ + + + перша стрічка + друга стрічка + diff --git a/tests/e2e/fixtures/excluded-languages/sources/1_android.xml b/tests/e2e/fixtures/excluded-languages/sources/1_android.xml new file mode 100644 index 000000000..53a60de70 --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/sources/1_android.xml @@ -0,0 +1,7 @@ + + + first string + second string + third string + + diff --git a/tests/e2e/fixtures/excluded-languages/sources/2_android.xml b/tests/e2e/fixtures/excluded-languages/sources/2_android.xml new file mode 100644 index 000000000..98a996698 --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/sources/2_android.xml @@ -0,0 +1,6 @@ + + + first string + second string + + diff --git a/tests/e2e/fixtures/excluded-languages/translations/it/1_android.xml b/tests/e2e/fixtures/excluded-languages/translations/it/1_android.xml new file mode 100644 index 000000000..7cb784427 --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/translations/it/1_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + seconda stringa + + diff --git a/tests/e2e/fixtures/excluded-languages/translations/it/2_android.xml b/tests/e2e/fixtures/excluded-languages/translations/it/2_android.xml new file mode 100644 index 000000000..61ee4ef1f --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/translations/it/2_android.xml @@ -0,0 +1,7 @@ + + + prima stringa + seconda stringa + terza stringa + + diff --git a/tests/e2e/fixtures/excluded-languages/translations/uk/1_android.xml b/tests/e2e/fixtures/excluded-languages/translations/uk/1_android.xml new file mode 100644 index 000000000..fe647358d --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/translations/uk/1_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + + diff --git a/tests/e2e/fixtures/excluded-languages/translations/uk/2_android.xml b/tests/e2e/fixtures/excluded-languages/translations/uk/2_android.xml new file mode 100644 index 000000000..fe647358d --- /dev/null +++ b/tests/e2e/fixtures/excluded-languages/translations/uk/2_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + + diff --git a/tests/e2e/fixtures/export-options/alt-configs/base.yml b/tests/e2e/fixtures/export-options/alt-configs/base.yml new file mode 100644 index 000000000..e37ce7dcc --- /dev/null +++ b/tests/e2e/fixtures/export-options/alt-configs/base.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/export-options/alt-configs/export-only-approved.yml b/tests/e2e/fixtures/export-options/alt-configs/export-only-approved.yml new file mode 100644 index 000000000..0a3d1088a --- /dev/null +++ b/tests/e2e/fixtures/export-options/alt-configs/export-only-approved.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + export_only_approved: true diff --git a/tests/e2e/fixtures/export-options/alt-configs/passed-workflow.yml b/tests/e2e/fixtures/export-options/alt-configs/passed-workflow.yml new file mode 100644 index 000000000..bc9f1b454 --- /dev/null +++ b/tests/e2e/fixtures/export-options/alt-configs/passed-workflow.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + export_strings_that_passed_workflow: true diff --git a/tests/e2e/fixtures/export-options/alt-configs/skip-files-approved.yml b/tests/e2e/fixtures/export-options/alt-configs/skip-files-approved.yml new file mode 100644 index 000000000..b538a758b --- /dev/null +++ b/tests/e2e/fixtures/export-options/alt-configs/skip-files-approved.yml @@ -0,0 +1,12 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + skip_untranslated_files: true + export_only_approved: true diff --git a/tests/e2e/fixtures/export-options/alt-configs/skip-strings-approved.yml b/tests/e2e/fixtures/export-options/alt-configs/skip-strings-approved.yml new file mode 100644 index 000000000..a28cf6811 --- /dev/null +++ b/tests/e2e/fixtures/export-options/alt-configs/skip-strings-approved.yml @@ -0,0 +1,12 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + skip_untranslated_strings: true + export_only_approved: true diff --git a/tests/e2e/fixtures/export-options/alt-configs/skip-untranslated-files.yml b/tests/e2e/fixtures/export-options/alt-configs/skip-untranslated-files.yml new file mode 100644 index 000000000..9cb165bda --- /dev/null +++ b/tests/e2e/fixtures/export-options/alt-configs/skip-untranslated-files.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + skip_untranslated_files: true diff --git a/tests/e2e/fixtures/export-options/alt-configs/skip-untranslated-strings.yml b/tests/e2e/fixtures/export-options/alt-configs/skip-untranslated-strings.yml new file mode 100644 index 000000000..d21112e9d --- /dev/null +++ b/tests/e2e/fixtures/export-options/alt-configs/skip-untranslated-strings.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + skip_untranslated_strings: true diff --git a/tests/e2e/fixtures/export-options/alt-configs/two-groups.yml b/tests/e2e/fixtures/export-options/alt-configs/two-groups.yml new file mode 100644 index 000000000..ef5a82c55 --- /dev/null +++ b/tests/e2e/fixtures/export-options/alt-configs/two-groups.yml @@ -0,0 +1,18 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/1_android.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + skip_untranslated_strings: true + skip_untranslated_files: false + export_only_approved: true + - source: "/sources/2_android.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + skip_untranslated_strings: false + skip_untranslated_files: true + export_only_approved: false diff --git a/tests/e2e/fixtures/export-options/config/crowdin.yml b/tests/e2e/fixtures/export-options/config/crowdin.yml new file mode 100644 index 000000000..e37ce7dcc --- /dev/null +++ b/tests/e2e/fixtures/export-options/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/export-options/expected/approved/it/1_android.xml b/tests/e2e/fixtures/export-options/expected/approved/it/1_android.xml new file mode 100644 index 000000000..3f2883f39 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/approved/it/1_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + second string + third string + diff --git a/tests/e2e/fixtures/export-options/expected/approved/it/2_android.xml b/tests/e2e/fixtures/export-options/expected/approved/it/2_android.xml new file mode 100644 index 000000000..ccc1a69d9 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/approved/it/2_android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/export-options/expected/approved/uk/1_android.xml b/tests/e2e/fixtures/export-options/expected/approved/uk/1_android.xml new file mode 100644 index 000000000..19888c435 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/approved/uk/1_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + третя стрічка + diff --git a/tests/e2e/fixtures/export-options/expected/approved/uk/2_android.xml b/tests/e2e/fixtures/export-options/expected/approved/uk/2_android.xml new file mode 100644 index 000000000..ccc1a69d9 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/approved/uk/2_android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/export-options/expected/skip-files/it/1_android.xml b/tests/e2e/fixtures/export-options/expected/skip-files/it/1_android.xml new file mode 100644 index 000000000..c493d20c6 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-files/it/1_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + seconda stringa + terza stringa + diff --git a/tests/e2e/fixtures/export-options/expected/skip-files/uk/1_android.xml b/tests/e2e/fixtures/export-options/expected/skip-files/uk/1_android.xml new file mode 100644 index 000000000..19888c435 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-files/uk/1_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + третя стрічка + diff --git a/tests/e2e/fixtures/export-options/expected/skip-strings-approved/it/1_android.xml b/tests/e2e/fixtures/export-options/expected/skip-strings-approved/it/1_android.xml new file mode 100644 index 000000000..96fbe6ad1 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-strings-approved/it/1_android.xml @@ -0,0 +1,4 @@ + + + prima stringa + diff --git a/tests/e2e/fixtures/export-options/expected/skip-strings-approved/it/2_android.xml b/tests/e2e/fixtures/export-options/expected/skip-strings-approved/it/2_android.xml new file mode 100644 index 000000000..3ea04e700 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-strings-approved/it/2_android.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/e2e/fixtures/export-options/expected/skip-strings-approved/uk/1_android.xml b/tests/e2e/fixtures/export-options/expected/skip-strings-approved/uk/1_android.xml new file mode 100644 index 000000000..19888c435 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-strings-approved/uk/1_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + третя стрічка + diff --git a/tests/e2e/fixtures/export-options/expected/skip-strings-approved/uk/2_android.xml b/tests/e2e/fixtures/export-options/expected/skip-strings-approved/uk/2_android.xml new file mode 100644 index 000000000..3ea04e700 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-strings-approved/uk/2_android.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/e2e/fixtures/export-options/expected/skip-strings/it/1_android.xml b/tests/e2e/fixtures/export-options/expected/skip-strings/it/1_android.xml new file mode 100644 index 000000000..c493d20c6 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-strings/it/1_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + seconda stringa + terza stringa + diff --git a/tests/e2e/fixtures/export-options/expected/skip-strings/it/2_android.xml b/tests/e2e/fixtures/export-options/expected/skip-strings/it/2_android.xml new file mode 100644 index 000000000..a8b58e114 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-strings/it/2_android.xml @@ -0,0 +1,4 @@ + + + prima stringa + diff --git a/tests/e2e/fixtures/export-options/expected/skip-strings/uk/1_android.xml b/tests/e2e/fixtures/export-options/expected/skip-strings/uk/1_android.xml new file mode 100644 index 000000000..19888c435 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-strings/uk/1_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + третя стрічка + diff --git a/tests/e2e/fixtures/export-options/expected/skip-strings/uk/2_android.xml b/tests/e2e/fixtures/export-options/expected/skip-strings/uk/2_android.xml new file mode 100644 index 000000000..e64436bb6 --- /dev/null +++ b/tests/e2e/fixtures/export-options/expected/skip-strings/uk/2_android.xml @@ -0,0 +1,4 @@ + + + перша стрічка + diff --git a/tests/e2e/fixtures/export-options/sources/1_android.xml b/tests/e2e/fixtures/export-options/sources/1_android.xml new file mode 100644 index 000000000..2a076fb3b --- /dev/null +++ b/tests/e2e/fixtures/export-options/sources/1_android.xml @@ -0,0 +1,6 @@ + + + first string + second string + third string + diff --git a/tests/e2e/fixtures/export-options/sources/2_android.xml b/tests/e2e/fixtures/export-options/sources/2_android.xml new file mode 100644 index 000000000..8a74db124 --- /dev/null +++ b/tests/e2e/fixtures/export-options/sources/2_android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/export-options/translations-approved/it/1_android.xml b/tests/e2e/fixtures/export-options/translations-approved/it/1_android.xml new file mode 100644 index 000000000..e248e5035 --- /dev/null +++ b/tests/e2e/fixtures/export-options/translations-approved/it/1_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + second string + third string + diff --git a/tests/e2e/fixtures/export-options/translations-approved/it/2_android.xml b/tests/e2e/fixtures/export-options/translations-approved/it/2_android.xml new file mode 100644 index 000000000..8a74db124 --- /dev/null +++ b/tests/e2e/fixtures/export-options/translations-approved/it/2_android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/export-options/translations-approved/uk/1_android.xml b/tests/e2e/fixtures/export-options/translations-approved/uk/1_android.xml new file mode 100644 index 000000000..db76c02d9 --- /dev/null +++ b/tests/e2e/fixtures/export-options/translations-approved/uk/1_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + третя стрічка + diff --git a/tests/e2e/fixtures/export-options/translations-approved/uk/2_android.xml b/tests/e2e/fixtures/export-options/translations-approved/uk/2_android.xml new file mode 100644 index 000000000..8a74db124 --- /dev/null +++ b/tests/e2e/fixtures/export-options/translations-approved/uk/2_android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/export-options/translations/it/1_android.xml b/tests/e2e/fixtures/export-options/translations/it/1_android.xml new file mode 100644 index 000000000..7e491e403 --- /dev/null +++ b/tests/e2e/fixtures/export-options/translations/it/1_android.xml @@ -0,0 +1,6 @@ + + + prima stringa + seconda stringa + terza stringa + diff --git a/tests/e2e/fixtures/export-options/translations/it/2_android.xml b/tests/e2e/fixtures/export-options/translations/it/2_android.xml new file mode 100644 index 000000000..493f62308 --- /dev/null +++ b/tests/e2e/fixtures/export-options/translations/it/2_android.xml @@ -0,0 +1,5 @@ + + + prima stringa + second string + diff --git a/tests/e2e/fixtures/export-options/translations/uk/1_android.xml b/tests/e2e/fixtures/export-options/translations/uk/1_android.xml new file mode 100644 index 000000000..db76c02d9 --- /dev/null +++ b/tests/e2e/fixtures/export-options/translations/uk/1_android.xml @@ -0,0 +1,6 @@ + + + перша стрічка + друга стрічка + третя стрічка + diff --git a/tests/e2e/fixtures/export-options/translations/uk/2_android.xml b/tests/e2e/fixtures/export-options/translations/uk/2_android.xml new file mode 100644 index 000000000..03d26a519 --- /dev/null +++ b/tests/e2e/fixtures/export-options/translations/uk/2_android.xml @@ -0,0 +1,5 @@ + + + перша стрічка + second string + diff --git a/tests/e2e/fixtures/file-groups/config/crowdin.yml b/tests/e2e/fixtures/file-groups/config/crowdin.yml new file mode 100644 index 000000000..69853f9f1 --- /dev/null +++ b/tests/e2e/fixtures/file-groups/config/crowdin.yml @@ -0,0 +1,18 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +# '/sources/*.pot' never matches anything, on purpose. An empty group is reported and the run keeps +# going, so its position in the list does not change what gets uploaded. +files: + - source: "/sources/*.properties" + translation: "/translations/%two_letters_code%/%original_file_name%" + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + - source: "/sources/*.pot" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/file-groups/sources/android.xml b/tests/e2e/fixtures/file-groups/sources/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/file-groups/sources/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/file-groups/sources/java.properties b/tests/e2e/fixtures/file-groups/sources/java.properties new file mode 100644 index 000000000..39cc5cc6a --- /dev/null +++ b/tests/e2e/fixtures/file-groups/sources/java.properties @@ -0,0 +1,2 @@ +str1=first string +str2=second string \ No newline at end of file diff --git a/tests/e2e/fixtures/file-groups/translations/it/android.xml b/tests/e2e/fixtures/file-groups/translations/it/android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/file-groups/translations/it/android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/file-groups/translations/it/java.properties b/tests/e2e/fixtures/file-groups/translations/it/java.properties new file mode 100644 index 000000000..39cc5cc6a --- /dev/null +++ b/tests/e2e/fixtures/file-groups/translations/it/java.properties @@ -0,0 +1,2 @@ +str1=first string +str2=second string \ No newline at end of file diff --git a/tests/e2e/fixtures/file-groups/translations/uk/android.xml b/tests/e2e/fixtures/file-groups/translations/uk/android.xml new file mode 100644 index 000000000..293507095 --- /dev/null +++ b/tests/e2e/fixtures/file-groups/translations/uk/android.xml @@ -0,0 +1,5 @@ + + + першоа стрічка + друга стрічка + diff --git a/tests/e2e/fixtures/file-groups/translations/uk/java.properties b/tests/e2e/fixtures/file-groups/translations/uk/java.properties new file mode 100644 index 000000000..39cc5cc6a --- /dev/null +++ b/tests/e2e/fixtures/file-groups/translations/uk/java.properties @@ -0,0 +1,2 @@ +str1=first string +str2=second string \ No newline at end of file diff --git a/tests/e2e/fixtures/file-tree/config/crowdin.yml b/tests/e2e/fixtures/file-tree/config/crowdin.yml new file mode 100644 index 000000000..f11768209 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "./files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/php/**/Bundle.properties" + translation: "/%two_letters_code%/%original_path%/Bundle.properties" diff --git a/tests/e2e/fixtures/file-tree/files/it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties new file mode 100644 index 000000000..cd710d074 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_HudsonPhpSupport=Supporto PHP per Hudson/Jenkins +LBL_ChecklistLabel=Esegui i test PHPUnit prima della build diff --git a/tests/e2e/fixtures/file-tree/files/it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties b/tests/e2e/fixtures/file-tree/files/it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties new file mode 100644 index 000000000..ffb2efc3d --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties @@ -0,0 +1,2 @@ +LBL_JavaCupLibrary=Generatore di parser CUP per Java +LBL_JavaCupVersion=java-cup-11a diff --git a/tests/e2e/fixtures/file-tree/files/it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties new file mode 100644 index 000000000..ca8cba06a --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpAnnotationApi=API per le annotazioni PHP +LBL_PhpAnnotationDesc=Supporto per annotazioni in stile PHPDoc diff --git a/tests/e2e/fixtures/file-tree/files/it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties new file mode 100644 index 000000000..60508eaa1 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpDocumentationApi=API di documentazione PHP +LBL_PhpDocumentationDesc=Registro dei fornitori di documentazione per l'editor PHP diff --git a/tests/e2e/fixtures/file-tree/files/it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties new file mode 100644 index 000000000..d7718eeaf --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpEditorApi=API dell'editor PHP +LBL_PhpEditorDesc=Punti di estensione dell'editor per il supporto del linguaggio PHP diff --git a/tests/e2e/fixtures/file-tree/files/php/hudson.php/nbproject/project.properties b/tests/e2e/fixtures/file-tree/files/php/hudson.php/nbproject/project.properties new file mode 100644 index 000000000..73f60bc0d --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/hudson.php/nbproject/project.properties @@ -0,0 +1,2 @@ +javac.source=1.8 +javac.compilerargs=-Xlint:unchecked diff --git a/tests/e2e/fixtures/file-tree/files/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties new file mode 100644 index 000000000..24ce0489a --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_HudsonPhpSupport=PHP support for Hudson/Jenkins builders +LBL_ChecklistLabel=Run PHPUnit tests before build diff --git a/tests/e2e/fixtures/file-tree/files/php/hudson.php/src/org/netbeans/modules/hudson/php/ui/options/HudsonOptionsPanel.form b/tests/e2e/fixtures/file-tree/files/php/hudson.php/src/org/netbeans/modules/hudson/php/ui/options/HudsonOptionsPanel.form new file mode 100644 index 000000000..8c6d52db3 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/hudson.php/src/org/netbeans/modules/hudson/php/ui/options/HudsonOptionsPanel.form @@ -0,0 +1,3 @@ + + +
diff --git a/tests/e2e/fixtures/file-tree/files/php/libs.javacup/external/binaries-list b/tests/e2e/fixtures/file-tree/files/php/libs.javacup/external/binaries-list new file mode 100644 index 000000000..4ed140c5b --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/libs.javacup/external/binaries-list @@ -0,0 +1 @@ +java-cup-11a.jar diff --git a/tests/e2e/fixtures/file-tree/files/php/libs.javacup/external/java-cup-11a-license.txt b/tests/e2e/fixtures/file-tree/files/php/libs.javacup/external/java-cup-11a-license.txt new file mode 100644 index 000000000..331a8411a --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/libs.javacup/external/java-cup-11a-license.txt @@ -0,0 +1 @@ +Placeholder license text for the bundled java-cup-11a binary. diff --git a/tests/e2e/fixtures/file-tree/files/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties b/tests/e2e/fixtures/file-tree/files/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties new file mode 100644 index 000000000..3f20027f4 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties @@ -0,0 +1,2 @@ +LBL_JavaCupLibrary=CUP Parser Generator for Java +LBL_JavaCupVersion=java-cup-11a diff --git a/tests/e2e/fixtures/file-tree/files/php/php.api.annotation/nbproject/project.properties b/tests/e2e/fixtures/file-tree/files/php/php.api.annotation/nbproject/project.properties new file mode 100644 index 000000000..73f60bc0d --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/php.api.annotation/nbproject/project.properties @@ -0,0 +1,2 @@ +javac.source=1.8 +javac.compilerargs=-Xlint:unchecked diff --git a/tests/e2e/fixtures/file-tree/files/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties new file mode 100644 index 000000000..20d56704e --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpAnnotationApi=PHP Annotations API +LBL_PhpAnnotationDesc=Support for PHPDoc-style annotations diff --git a/tests/e2e/fixtures/file-tree/files/php/php.api.documentation/nbproject/project.properties b/tests/e2e/fixtures/file-tree/files/php/php.api.documentation/nbproject/project.properties new file mode 100644 index 000000000..73f60bc0d --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/php.api.documentation/nbproject/project.properties @@ -0,0 +1,2 @@ +javac.source=1.8 +javac.compilerargs=-Xlint:unchecked diff --git a/tests/e2e/fixtures/file-tree/files/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties new file mode 100644 index 000000000..d0a556739 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpDocumentationApi=PHP Documentation API +LBL_PhpDocumentationDesc=Documentation provider registry for PHP editor diff --git a/tests/e2e/fixtures/file-tree/files/php/php.api.editor/nbproject/project.properties b/tests/e2e/fixtures/file-tree/files/php/php.api.editor/nbproject/project.properties new file mode 100644 index 000000000..73f60bc0d --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/php.api.editor/nbproject/project.properties @@ -0,0 +1,2 @@ +javac.source=1.8 +javac.compilerargs=-Xlint:unchecked diff --git a/tests/e2e/fixtures/file-tree/files/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties new file mode 100644 index 000000000..93b0c1309 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpEditorApi=PHP Editor API +LBL_PhpEditorDesc=Editor extension points for PHP language support diff --git a/tests/e2e/fixtures/file-tree/files/uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties new file mode 100644 index 000000000..d52af8ec1 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_HudsonPhpSupport=Підтримка PHP для Hudson/Jenkins +LBL_ChecklistLabel=Запускати тести PHPUnit перед збіркою diff --git a/tests/e2e/fixtures/file-tree/files/uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties b/tests/e2e/fixtures/file-tree/files/uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties new file mode 100644 index 000000000..da449d954 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties @@ -0,0 +1,2 @@ +LBL_JavaCupLibrary=Генератор парсерів CUP для Java +LBL_JavaCupVersion=java-cup-11a diff --git a/tests/e2e/fixtures/file-tree/files/uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties new file mode 100644 index 000000000..c6142b526 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpAnnotationApi=API анотацій PHP +LBL_PhpAnnotationDesc=Підтримка анотацій у стилі PHPDoc diff --git a/tests/e2e/fixtures/file-tree/files/uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties new file mode 100644 index 000000000..5ea112a85 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpDocumentationApi=API документації PHP +LBL_PhpDocumentationDesc=Реєстр постачальників документації для редактора PHP diff --git a/tests/e2e/fixtures/file-tree/files/uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties b/tests/e2e/fixtures/file-tree/files/uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties new file mode 100644 index 000000000..ff3fd0f46 --- /dev/null +++ b/tests/e2e/fixtures/file-tree/files/uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties @@ -0,0 +1,2 @@ +LBL_PhpEditorApi=API редактора PHP +LBL_PhpEditorDesc=Точки розширення редактора для підтримки мови PHP diff --git a/tests/e2e/fixtures/file-type/alt-configs/file-type.yml b/tests/e2e/fixtures/file-type/alt-configs/file-type.yml new file mode 100644 index 000000000..32870d716 --- /dev/null +++ b/tests/e2e/fixtures/file-type/alt-configs/file-type.yml @@ -0,0 +1,9 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "./files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/android.xml" + translation: "/android%two_letters_code%.xml" + type: "{{type}}" diff --git a/tests/e2e/fixtures/file-type/config/crowdin.yml b/tests/e2e/fixtures/file-type/config/crowdin.yml new file mode 100644 index 000000000..3ad75dc93 --- /dev/null +++ b/tests/e2e/fixtures/file-type/config/crowdin.yml @@ -0,0 +1,9 @@ +# Every test switches to `alt-configs/file-type.yml` with its own `type`, so this one only has to render. +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "./files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/android.xml" + translation: "/android%two_letters_code%.xml" diff --git a/tests/e2e/fixtures/file-type/files/android.xml b/tests/e2e/fixtures/file-type/files/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/file-type/files/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/file/config/crowdin.yml b/tests/e2e/fixtures/file/config/crowdin.yml new file mode 100644 index 000000000..576449444 --- /dev/null +++ b/tests/e2e/fixtures/file/config/crowdin.yml @@ -0,0 +1,4 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/file/sources/app.xml b/tests/e2e/fixtures/file/sources/app.xml new file mode 100644 index 000000000..dac2bd9fa --- /dev/null +++ b/tests/e2e/fixtures/file/sources/app.xml @@ -0,0 +1,5 @@ + + + Welcome aboard + Log out + diff --git a/tests/e2e/fixtures/file/sources/extra.xml b/tests/e2e/fixtures/file/sources/extra.xml new file mode 100644 index 000000000..b83f8fcea --- /dev/null +++ b/tests/e2e/fixtures/file/sources/extra.xml @@ -0,0 +1,4 @@ + + + Temporary string + diff --git a/tests/e2e/fixtures/file/translations/uk/app.xml b/tests/e2e/fixtures/file/translations/uk/app.xml new file mode 100644 index 000000000..4b45dd5c6 --- /dev/null +++ b/tests/e2e/fixtures/file/translations/uk/app.xml @@ -0,0 +1,5 @@ + + + Ласкаво просимо + Вийти + diff --git a/tests/e2e/fixtures/full-cli-workflow/config/crowdin.yml b/tests/e2e/fixtures/full-cli-workflow/config/crowdin.yml new file mode 100644 index 000000000..599bf0bae --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +files: + - source: "/sources/*.md" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/full-cli-workflow/sources/alpha.md b/tests/e2e/fixtures/full-cli-workflow/sources/alpha.md new file mode 100644 index 000000000..dcf11dd00 --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/sources/alpha.md @@ -0,0 +1,3 @@ +# Alpha + +Welcome to the alpha document. diff --git a/tests/e2e/fixtures/full-cli-workflow/sources/beta.md b/tests/e2e/fixtures/full-cli-workflow/sources/beta.md new file mode 100644 index 000000000..95624588b --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/sources/beta.md @@ -0,0 +1,3 @@ +# Beta + +The beta document has some content too. diff --git a/tests/e2e/fixtures/full-cli-workflow/sources/gamma.md b/tests/e2e/fixtures/full-cli-workflow/sources/gamma.md new file mode 100644 index 000000000..e67e0e10e --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/sources/gamma.md @@ -0,0 +1,3 @@ +# Gamma + +Gamma wraps up the trio of source files. diff --git a/tests/e2e/fixtures/full-cli-workflow/translations/it/alpha.md b/tests/e2e/fixtures/full-cli-workflow/translations/it/alpha.md new file mode 100644 index 000000000..980e20ad7 --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/translations/it/alpha.md @@ -0,0 +1,3 @@ +# Alfa + +Benvenuto nel documento alfa. diff --git a/tests/e2e/fixtures/full-cli-workflow/translations/it/beta.md b/tests/e2e/fixtures/full-cli-workflow/translations/it/beta.md new file mode 100644 index 000000000..2ecc231f7 --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/translations/it/beta.md @@ -0,0 +1,3 @@ +# Beta + +Anche il documento beta ha dei contenuti. diff --git a/tests/e2e/fixtures/full-cli-workflow/translations/it/gamma.md b/tests/e2e/fixtures/full-cli-workflow/translations/it/gamma.md new file mode 100644 index 000000000..02a037966 --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/translations/it/gamma.md @@ -0,0 +1,3 @@ +# Gamma + +Gamma completa il trio dei file sorgente. diff --git a/tests/e2e/fixtures/full-cli-workflow/translations/uk/alpha.md b/tests/e2e/fixtures/full-cli-workflow/translations/uk/alpha.md new file mode 100644 index 000000000..0289b5aa5 --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/translations/uk/alpha.md @@ -0,0 +1,3 @@ +# Альфа + +Ласкаво просимо до документа альфа. diff --git a/tests/e2e/fixtures/full-cli-workflow/translations/uk/beta.md b/tests/e2e/fixtures/full-cli-workflow/translations/uk/beta.md new file mode 100644 index 000000000..588485cd1 --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/translations/uk/beta.md @@ -0,0 +1,3 @@ +# Бета + +Документ бета також має певний вміст. diff --git a/tests/e2e/fixtures/full-cli-workflow/translations/uk/gamma.md b/tests/e2e/fixtures/full-cli-workflow/translations/uk/gamma.md new file mode 100644 index 000000000..ffd5a41ce --- /dev/null +++ b/tests/e2e/fixtures/full-cli-workflow/translations/uk/gamma.md @@ -0,0 +1,3 @@ +# Гамма + +Гамма завершує трійку вихідних файлів. diff --git a/tests/e2e/fixtures/glossary/alt-configs/without-token.yml b/tests/e2e/fixtures/glossary/alt-configs/without-token.yml new file mode 100644 index 000000000..a7e5b760c --- /dev/null +++ b/tests/e2e/fixtures/glossary/alt-configs/without-token.yml @@ -0,0 +1,8 @@ +base_path: "." +base_url: "https://api.crowdin.com" + +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/glossary/config/crowdin.yml b/tests/e2e/fixtures/glossary/config/crowdin.yml new file mode 100644 index 000000000..6934e7e6c --- /dev/null +++ b/tests/e2e/fixtures/glossary/config/crowdin.yml @@ -0,0 +1,3 @@ +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/glossary/expected/simple-glossary.csv b/tests/e2e/fixtures/glossary/expected/simple-glossary.csv new file mode 100644 index 000000000..635085e59 --- /dev/null +++ b/tests/e2e/fixtures/glossary/expected/simple-glossary.csv @@ -0,0 +1,6 @@ +"Term [en]","Description [en]","Part of Speech [en]","Term [ar]","Description [ar]","Part of Speech [ar]","Term [zh-CN]","Description [zh-CN]","Part of Speech [zh-CN]","Term [de]","Description [de]","Part of Speech [de]","Term [uk]","Description [uk]","Part of Speech [uk]" +applet,"A program that is run by an application.",ADJ,بريمج,"برنامج يتم تشغيله من قبل تطبيق ما.",,小程序,由应用程序运行的程序。,,Applet,"Ein Programm, die von einer Anwendung ausgeführt wird.",,аплет,"Програма, яка знаходиться у веденні програми.", +bootloader,"Software that boots and loads an operating system. Also allows a user to choose between multiple operating systems - if you have.",ADJ,"محمل الإقلاع","برنامج الأحذية ويقوم بتحميل نظام التشغيل. كما يسمح لمستخدم بالاختيار من بين العديد من أنظمة التشغيل--إذا كان لديك.",,引导装载程序,启动并加载操作系统软件。如果你有,还允许用户选择多个操作系统-之间。,,Bootloader,"Software, die startet und lädt ein Betriebssystem. Auch ermöglicht einem Benutzer, mehrere Betriebssysteme - Auswahl haben Sie.",,завантажувач,"Програмне забезпечення, яке чоботи і завантажує операційної системи. Дозволяє користувачеві вибирати між кількома операційними системами - якщо у вас є.", +codec,"A piece of software design to encode and decode (plays) digital data, especially audio and video streams.",ADJ,"برنامج الترميز","قطعة من تصميم البرمجيات لترميز وفك ترميز البيانات الرقمية (يلعب)، لا سيما الصوت ودفق الفيديو.",,编解码器,"一块的软件设计,编码和解码 (戏剧) 数字数据,尤其是音频和视频流。",,Codec,"Ein Stück Software-Design zu codieren und decodieren (Stücke) digitale Daten, vor allem Audio- und video-Streams.",,кодек,"Частина проектування програмного забезпечення для кодування та декодування (п'єси) цифрових даних, особливо для аудіо та відео потоків.", +directory,"A virtual container within a digital file system, in which groups of files and other directories can be kept and organized.",ADJ,الدليل,"حاوية افتراضية ضمن نظام ملفات رقمية، التي يمكن الاحتفاظ بمجموعات من الملفات والدلائل الأخرى وتنظيمها.",,目录,数字文件在系统内,可以保持和组织团体的文件和其他目录的虚拟容器。,,Verzeichnis,"Ein virtueller Container innerhalb eines digitalen Dateisystems, in dem Gruppen von Dateien und andere Verzeichnisse gehalten und organisiert werden können.",,директорія,"Віртуальний контейнера в рамках системи цифрового файлу, в якому групи файлів та інші каталоги можуть зберігатися і організовані.", +"ext3 (or ""third extended filesystem"")","A popular file system used in many Linux distributions, including Ubuntu. The file system is the operating system's method of categorizing and storing data on physical and network drives. ext3's counterpart in Windows is NTFS (or NT File System).",ADJ,"ext3 (أو ""نظام الملفات الموسعة الثالثة"")","نظام ملفات شعبية المستخدمة في العديد من توزيعات لينكس، بما في ذلك أوبونتو. نظام الملفات هو الأسلوب في نظام التشغيل لتصنيف وتخزين البيانات على البدنية ومحركات أقراص شبكة الاتصال. النظيرة ل ext3 في Windows هو NTFS (أو نظام ملفات NT).",,"ext3 (或者""第三个扩展文件系统"")","在许多 Linux 发行版,包括 Ubuntu 中使用流行的文件系统。文件系统是操作系统的方法进行分类和存储数据的物理和网络驱动器。在 Windows 中的 ext3 的对应是 NTFS (或 NT 文件系统)。",,"ext3 (oder ""dritte erweiterte Dateisystem"")","Ein beliebtes Dateisystem verwendet in vielen Linux-Distributionen, wie Ubuntu. Das Dateisystem ist das Betriebssystem Methode der Kategorisierung und Speicherung von Daten auf physischen und Netzlaufwerke. ext3 Gegenstück in Windows ist NTFS (oder NT-Dateisystem).",,"ext3 (або ""третій розширена файлова система"")","Популярні файлова система використовується у багатьох дистрибутивах Linux, у тому числі Ubuntu. Файлова система – операційну систему метод класифікації та зберігання даних на фізичну і мережеві диски. аналог в ext3 у Windows є NTFS (або файлова система NT).", diff --git a/tests/e2e/fixtures/glossary/expected/simple-glossary.xlsx b/tests/e2e/fixtures/glossary/expected/simple-glossary.xlsx new file mode 100644 index 000000000..f18d7527d Binary files /dev/null and b/tests/e2e/fixtures/glossary/expected/simple-glossary.xlsx differ diff --git a/tests/e2e/fixtures/glossary/sources/extra-glossary.tbx b/tests/e2e/fixtures/glossary/sources/extra-glossary.tbx new file mode 100644 index 000000000..0301a9270 --- /dev/null +++ b/tests/e2e/fixtures/glossary/sources/extra-glossary.tbx @@ -0,0 +1,45 @@ + + + + + +

Crowdin glossary download

+
+
+ +

http://www.lisa.org/fileadmin/standards/tbx_basic/TBXBasicXCSV02.xcs

+
+
+ + + + + + checksum + checksum description + + + + + контрольна сума + опис контрольної суми + + + + + + + payload + payload description + + + + + корисне навантаження + опис корисного навантаження + + + + + +
diff --git a/tests/e2e/fixtures/glossary/sources/simple-glossary.csv b/tests/e2e/fixtures/glossary/sources/simple-glossary.csv new file mode 100644 index 000000000..22aa00c6c --- /dev/null +++ b/tests/e2e/fixtures/glossary/sources/simple-glossary.csv @@ -0,0 +1,6 @@ +,term en,part of spetch en,description en,term ar,description ar,term zh-CN,description zh-CN,term de,description de,term uk,description uk +1,applet,ADJ,A program that is run by an application.,بريمج,برنامج يتم تشغيله من قبل تطبيق ما.,小程序,由应用程序运行的程序。,Applet,"Ein Programm, die von einer Anwendung ausgeführt wird.",аплет,"Програма, яка знаходиться у веденні програми." +2,bootloader,ADJ,Software that boots and loads an operating system. Also allows a user to choose between multiple operating systems - if you have.,محمل الإقلاع,برنامج الأحذية ويقوم بتحميل نظام التشغيل. كما يسمح لمستخدم بالاختيار من بين العديد من أنظمة التشغيل--إذا كان لديك.,引导装载程序,启动并加载操作系统软件。如果你有,还允许用户选择多个操作系统-之间。,Bootloader,"Software, die startet und lädt ein Betriebssystem. Auch ermöglicht einem Benutzer, mehrere Betriebssysteme - Auswahl haben Sie.",завантажувач,"Програмне забезпечення, яке чоботи і завантажує операційної системи. Дозволяє користувачеві вибирати між кількома операційними системами - якщо у вас є." +3,codec,ADJ,"A piece of software design to encode and decode (plays) digital data, especially audio and video streams.",برنامج الترميز,قطعة من تصميم البرمجيات لترميز وفك ترميز البيانات الرقمية (يلعب)، لا سيما الصوت ودفق الفيديو.,编解码器,一块的软件设计,编码和解码 (戏剧) 数字数据,尤其是音频和视频流。,Codec,"Ein Stück Software-Design zu codieren und decodieren (Stücke) digitale Daten, vor allem Audio- und video-Streams.",кодек,"Частина проектування програмного забезпечення для кодування та декодування (п'єси) цифрових даних, особливо для аудіо та відео потоків." +4,directory,ADJ,"A virtual container within a digital file system, in which groups of files and other directories can be kept and organized.",الدليل,حاوية افتراضية ضمن نظام ملفات رقمية، التي يمكن الاحتفاظ بمجموعات من الملفات والدلائل الأخرى وتنظيمها.,目录,数字文件在系统内,可以保持和组织团体的文件和其他目录的虚拟容器。,Verzeichnis,"Ein virtueller Container innerhalb eines digitalen Dateisystems, in dem Gruppen von Dateien und andere Verzeichnisse gehalten und organisiert werden können.",директорія,"Віртуальний контейнера в рамках системи цифрового файлу, в якому групи файлів та інші каталоги можуть зберігатися і організовані." +5,"ext3 (or ""third extended filesystem"")",ADJ,"A popular file system used in many Linux distributions, including Ubuntu. The file system is the operating system's method of categorizing and storing data on physical and network drives. ext3's counterpart in Windows is NTFS (or NT File System).","ext3 (أو ""نظام الملفات الموسعة الثالثة"")",نظام ملفات شعبية المستخدمة في العديد من توزيعات لينكس، بما في ذلك أوبونتو. نظام الملفات هو الأسلوب في نظام التشغيل لتصنيف وتخزين البيانات على البدنية ومحركات أقراص شبكة الاتصال. النظيرة ل ext3 في Windows هو NTFS (أو نظام ملفات NT).,"ext3 (或者""第三个扩展文件系统"")",在许多 Linux 发行版,包括 Ubuntu 中使用流行的文件系统。文件系统是操作系统的方法进行分类和存储数据的物理和网络驱动器。在 Windows 中的 ext3 的对应是 NTFS (或 NT 文件系统)。,"ext3 (oder ""dritte erweiterte Dateisystem"")","Ein beliebtes Dateisystem verwendet in vielen Linux-Distributionen, wie Ubuntu. Das Dateisystem ist das Betriebssystem Methode der Kategorisierung und Speicherung von Daten auf physischen und Netzlaufwerke. ext3 Gegenstück in Windows ist NTFS (oder NT-Dateisystem).","ext3 (або ""третій розширена файлова система"")","Популярні файлова система використовується у багатьох дистрибутивах Linux, у тому числі Ubuntu. Файлова система – операційну систему метод класифікації та зберігання даних на фізичну і мережеві диски. аналог в ext3 у Windows є NTFS (або файлова система NT)." diff --git a/tests/e2e/fixtures/glossary/sources/simple-glossary.tbx b/tests/e2e/fixtures/glossary/sources/simple-glossary.tbx new file mode 100644 index 000000000..777d8c85b --- /dev/null +++ b/tests/e2e/fixtures/glossary/sources/simple-glossary.tbx @@ -0,0 +1,96 @@ + + + + + +

Crowdin glossary download

+
+
+ +

http://www.lisa.org/fileadmin/standards/tbx_basic/TBXBasicXCSV02.xcs

+
+
+ + + + + + first + first description + + + + + zuerst + zuerst Beschreibung + + + + + перший + перший опис + + + + + أول + الوصف الأول + + + + + 第一 + 第一个描述 + + + + + + + second + second description + + + + + zweite + zweite Beschreibung + + + + + другий + другий опис + + + + + + + third + + + + + dritte + + + + + третій + + + + + الثالث + + + + + 第三 + + + + + +
diff --git a/tests/e2e/fixtures/glossary/sources/simple-glossary.xlsx b/tests/e2e/fixtures/glossary/sources/simple-glossary.xlsx new file mode 100755 index 000000000..609bce285 Binary files /dev/null and b/tests/e2e/fixtures/glossary/sources/simple-glossary.xlsx differ diff --git a/tests/e2e/fixtures/glossary/sources/unsupported.txt b/tests/e2e/fixtures/glossary/sources/unsupported.txt new file mode 100644 index 000000000..e055f0188 --- /dev/null +++ b/tests/e2e/fixtures/glossary/sources/unsupported.txt @@ -0,0 +1 @@ +not a glossary diff --git a/tests/e2e/fixtures/identity/config/crowdin.yml b/tests/e2e/fixtures/identity/config/crowdin.yml new file mode 100644 index 000000000..aa8ea1cd7 --- /dev/null +++ b/tests/e2e/fixtures/identity/config/crowdin.yml @@ -0,0 +1,6 @@ +base_path: "." +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/identity/identity.yml b/tests/e2e/fixtures/identity/identity.yml new file mode 100644 index 000000000..13998d825 --- /dev/null +++ b/tests/e2e/fixtures/identity/identity.yml @@ -0,0 +1,4 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_url: "https://api.crowdin.com" +base_path: "." diff --git a/tests/e2e/fixtures/identity/sources/android.xml b/tests/e2e/fixtures/identity/sources/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/identity/sources/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/identity/translations/it/android.xml b/tests/e2e/fixtures/identity/translations/it/android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/identity/translations/it/android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/identity/translations/uk/android.xml b/tests/e2e/fixtures/identity/translations/uk/android.xml new file mode 100644 index 000000000..293507095 --- /dev/null +++ b/tests/e2e/fixtures/identity/translations/uk/android.xml @@ -0,0 +1,5 @@ + + + першоа стрічка + друга стрічка + diff --git a/tests/e2e/fixtures/ignore/alt-configs/ignore-hidden-files.yml b/tests/e2e/fixtures/ignore/alt-configs/ignore-hidden-files.yml new file mode 100644 index 000000000..c9faeb3fa --- /dev/null +++ b/tests/e2e/fixtures/ignore/alt-configs/ignore-hidden-files.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "./files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +settings: + ignore_hidden_files: {{ignoreHiddenFiles}} +files: + - source: "/folder/**/*.*" + translation: "/translations/%locale%/%original_file_name%" diff --git a/tests/e2e/fixtures/ignore/alt-configs/ignore.yml b/tests/e2e/fixtures/ignore/alt-configs/ignore.yml new file mode 100644 index 000000000..0d9a71035 --- /dev/null +++ b/tests/e2e/fixtures/ignore/alt-configs/ignore.yml @@ -0,0 +1,10 @@ +# `{{ignore}}` is the test's pattern list, rendered as a YAML flow sequence. +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "./files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/**/*.*" + translation: "/%original_path%/%file_name%-%two_letters_code%.%file_extension%" + ignore: {{ignore}} diff --git a/tests/e2e/fixtures/ignore/config/crowdin.yml b/tests/e2e/fixtures/ignore/config/crowdin.yml new file mode 100644 index 000000000..667908b46 --- /dev/null +++ b/tests/e2e/fixtures/ignore/config/crowdin.yml @@ -0,0 +1,9 @@ +# Every test switches to an alt-config before running, so this one only has to render. +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "./files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/**/*.*" + translation: "/%original_path%/%file_name%-%two_letters_code%.%file_extension%" diff --git a/tests/e2e/fixtures/ignore/files/1.txt b/tests/e2e/fixtures/ignore/files/1.txt new file mode 100644 index 000000000..b066da2b0 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/1.txt @@ -0,0 +1 @@ +first string diff --git a/tests/e2e/fixtures/ignore/files/1.xml b/tests/e2e/fixtures/ignore/files/1.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/1.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/123.xml b/tests/e2e/fixtures/ignore/files/123.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/123.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/123_test.xml b/tests/e2e/fixtures/ignore/files/123_test.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/123_test.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/a.xml b/tests/e2e/fixtures/ignore/files/a.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/a.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/android-uk.xml b/tests/e2e/fixtures/ignore/files/android-uk.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/android-uk.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/android.xml b/tests/e2e/fixtures/ignore/files/android.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/android.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/.hidden.xml b/tests/e2e/fixtures/ignore/files/folder/.hidden.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/.hidden.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/1.xml b/tests/e2e/fixtures/ignore/files/folder/1.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/1.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/123.xml b/tests/e2e/fixtures/ignore/files/folder/123.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/123.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/123_test.xml b/tests/e2e/fixtures/ignore/files/folder/123_test.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/123_test.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/a.xml b/tests/e2e/fixtures/ignore/files/folder/a.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/a.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/android-uk.xml b/tests/e2e/fixtures/ignore/files/folder/android-uk.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/android-uk.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/android.xml b/tests/e2e/fixtures/ignore/files/folder/android.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/android.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/sub/.hidden.xml b/tests/e2e/fixtures/ignore/files/folder/sub/.hidden.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/sub/.hidden.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/ignore/files/folder/sub/1.txt b/tests/e2e/fixtures/ignore/files/folder/sub/1.txt new file mode 100644 index 000000000..b066da2b0 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/sub/1.txt @@ -0,0 +1 @@ +first string diff --git a/tests/e2e/fixtures/ignore/files/folder/sub/1.xml b/tests/e2e/fixtures/ignore/files/folder/sub/1.xml new file mode 100644 index 000000000..f00d909b1 --- /dev/null +++ b/tests/e2e/fixtures/ignore/files/folder/sub/1.xml @@ -0,0 +1,4 @@ + + + first string + diff --git a/tests/e2e/fixtures/init/config/crowdin.yml b/tests/e2e/fixtures/init/config/crowdin.yml new file mode 100644 index 000000000..934a7a15a --- /dev/null +++ b/tests/e2e/fixtures/init/config/crowdin.yml @@ -0,0 +1,11 @@ +# `init --quiet -d ` and `config lint --config ` (this suite's only commands) both take +# an explicit destination/config path via CLI args, so this template is never actually read by any +# test - it exists only because setupSuite's standard lifecycle renders and requires one. +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "sources/*.md" + translation: "translations/%locale%/%original_file_name%" diff --git a/tests/e2e/fixtures/init/sources/messages.json b/tests/e2e/fixtures/init/sources/messages.json new file mode 100644 index 000000000..f750bd110 --- /dev/null +++ b/tests/e2e/fixtures/init/sources/messages.json @@ -0,0 +1,3 @@ +{ + "greeting": "Hello" +} diff --git a/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-base-url.yml b/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-base-url.yml new file mode 100644 index 000000000..f7ef13da3 --- /dev/null +++ b/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-base-url.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "http://crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-project-id.yml b/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-project-id.yml new file mode 100644 index 000000000..f1aca60b9 --- /dev/null +++ b/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-project-id.yml @@ -0,0 +1,8 @@ +project_id: "not-a-number" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-token.yml b/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-token.yml new file mode 100644 index 000000000..105437287 --- /dev/null +++ b/tests/e2e/fixtures/invalid-credentials/alt-configs/invalid-token.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "not-valid-token" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/invalid-credentials/alt-configs/nonexistent-base-path.yml b/tests/e2e/fixtures/invalid-credentials/alt-configs/nonexistent-base-path.yml new file mode 100644 index 000000000..24b1064c7 --- /dev/null +++ b/tests/e2e/fixtures/invalid-credentials/alt-configs/nonexistent-base-path.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "/not/exists/path" +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/invalid-credentials/alt-configs/nonexistent-project-id.yml b/tests/e2e/fixtures/invalid-credentials/alt-configs/nonexistent-project-id.yml new file mode 100644 index 000000000..094d9ec0b --- /dev/null +++ b/tests/e2e/fixtures/invalid-credentials/alt-configs/nonexistent-project-id.yml @@ -0,0 +1,8 @@ +project_id: "999999" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/invalid-credentials/config/crowdin.yml b/tests/e2e/fixtures/invalid-credentials/config/crowdin.yml new file mode 100644 index 000000000..249d48bbc --- /dev/null +++ b/tests/e2e/fixtures/invalid-credentials/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/invalid-credentials/sources/android.xml b/tests/e2e/fixtures/invalid-credentials/sources/android.xml new file mode 100644 index 000000000..3db26f896 --- /dev/null +++ b/tests/e2e/fixtures/invalid-credentials/sources/android.xml @@ -0,0 +1,6 @@ + + + first string + second string + third string + diff --git a/tests/e2e/fixtures/invalid-files-config/config/crowdin.yml b/tests/e2e/fixtures/invalid-files-config/config/crowdin.yml new file mode 100644 index 000000000..9a36b97c7 --- /dev/null +++ b/tests/e2e/fixtures/invalid-files-config/config/crowdin.yml @@ -0,0 +1,13 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +# Every test in this suite overrides `source`/`translation` via the `--source`/`--translation` CLI +# flags, so this default pair is only +# used as a valid fallback and is never exercised directly. +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/invalid-files-config/sources/android.xml b/tests/e2e/fixtures/invalid-files-config/sources/android.xml new file mode 100644 index 000000000..b69a70c02 --- /dev/null +++ b/tests/e2e/fixtures/invalid-files-config/sources/android.xml @@ -0,0 +1,8 @@ + + + first string source file1 + second string source file1 + third string source file1 + fourth string source file1 + fifth string source file1 + diff --git a/tests/e2e/fixtures/invalid-files-config/translations/it/android.xml b/tests/e2e/fixtures/invalid-files-config/translations/it/android.xml new file mode 100644 index 000000000..4fce04a90 --- /dev/null +++ b/tests/e2e/fixtures/invalid-files-config/translations/it/android.xml @@ -0,0 +1,9 @@ + + + la prima riga stringa revision2 file1 + seconda riga sorgente revision2 file1 + terza stringa sorgente revision2 file1 + quarta stringa sorgente revision2 file1 + quinta riga stringa revision2 file1 + sesta riga sorgente revision2 file1 + diff --git a/tests/e2e/fixtures/invalid-files-config/translations/uk/android.xml b/tests/e2e/fixtures/invalid-files-config/translations/uk/android.xml new file mode 100644 index 000000000..339db334c --- /dev/null +++ b/tests/e2e/fixtures/invalid-files-config/translations/uk/android.xml @@ -0,0 +1,9 @@ + + + джерело першої стрічки версія2 файл1 + джерело другої стрічки версія2 файл1 + джерело третьої стрічки версія2 файл1 + джерело четвертої стрічки версія2 файл1 + джерело п'ятої стрічки версія2 файл1 + джерело шостої стрічки версія2 файл1 + diff --git a/tests/e2e/fixtures/label/config/crowdin.yml b/tests/e2e/fixtures/label/config/crowdin.yml new file mode 100644 index 000000000..d6cd2475c --- /dev/null +++ b/tests/e2e/fixtures/label/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/label/sources/1_android.xml b/tests/e2e/fixtures/label/sources/1_android.xml new file mode 100644 index 000000000..9f4263e02 --- /dev/null +++ b/tests/e2e/fixtures/label/sources/1_android.xml @@ -0,0 +1,5 @@ + + + first string source file1 + second string source file1 + diff --git a/tests/e2e/fixtures/language-mapping/alt-configs/crowdin-no-mapping.yml b/tests/e2e/fixtures/language-mapping/alt-configs/crowdin-no-mapping.yml new file mode 100644 index 000000000..405ecb4b5 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/alt-configs/crowdin-no-mapping.yml @@ -0,0 +1,24 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +files: + - source: "/android_code/android.xml" + translation: "/android_code/%android_code%/%original_file_name%" + - source: "/language/android.xml" + translation: "/language/%language%/%file_name%.%file_extension%" + - source: "/locale/android.xml" + translation: "/locale/%locale%/android.xml" + - source: "/locale_with_underscore/android.xml" + translation: "/locale_with_underscore/%locale_with_underscore%/android.xml" + - source: "/osx_code/android.xml" + translation: "/osx_code/%osx_code%/android.xml" + - source: "/osx_locale/android.xml" + translation: "/osx_locale/%osx_locale%/android.xml" + - source: "/three_letters_code/android.xml" + translation: "/three_letters_code/%three_letters_code%/android.xml" + - source: "/two_letters_code/android.xml" + translation: "/two_letters_code/%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/language-mapping/android_code/android.xml b/tests/e2e/fixtures/language-mapping/android_code/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/android_code/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/language-mapping/android_code/uk-rUA_/android.xml b/tests/e2e/fixtures/language-mapping/android_code/uk-rUA_/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/android_code/uk-rUA_/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/android_code/uk-rUA_crwd/android.xml b/tests/e2e/fixtures/language-mapping/android_code/uk-rUA_crwd/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/android_code/uk-rUA_crwd/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/android_code/zh-rCN_/android.xml b/tests/e2e/fixtures/language-mapping/android_code/zh-rCN_/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/android_code/zh-rCN_/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language-mapping/android_code/zh-rCN_crwd/android.xml b/tests/e2e/fixtures/language-mapping/android_code/zh-rCN_crwd/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/android_code/zh-rCN_crwd/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language-mapping/config/crowdin.yml b/tests/e2e/fixtures/language-mapping/config/crowdin.yml new file mode 100644 index 000000000..9fb23e55e --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/config/crowdin.yml @@ -0,0 +1,56 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +files: + - source: "/android_code/android.xml" + translation: "/android_code/%android_code%/%original_file_name%" + languages_mapping: + android_code: + uk: "uk-rUA_" + zh-CN: "zh-rCN_" + - source: "/language/android.xml" + translation: "/language/%language%/%file_name%.%file_extension%" + languages_mapping: + language: + uk: "Ukrainian_" + zh-CN: "Chinese Simplified_" + - source: "/locale/android.xml" + translation: "/locale/%locale%/android.xml" + languages_mapping: + locale: + uk: "uk-UA_" + zh-CN: "zh-CN_" + - source: "/locale_with_underscore/android.xml" + translation: "/locale_with_underscore/%locale_with_underscore%/android.xml" + languages_mapping: + locale_with_underscore: + uk: "uk_UA_" + zh-CN: "zh_CN_" + - source: "/osx_code/android.xml" + translation: "/osx_code/%osx_code%/android.xml" + languages_mapping: + osx_code: + uk: "uk.lproj_" + zh-CN: "zh-Hans.lproj_" + - source: "/osx_locale/android.xml" + translation: "/osx_locale/%osx_locale%/android.xml" + languages_mapping: + osx_locale: + uk: "uk_" + zh-CN: "zh-Hans_" + - source: "/three_letters_code/android.xml" + translation: "/three_letters_code/%three_letters_code%/android.xml" + languages_mapping: + three_letters_code: + uk: "ukr_" + zh-CN: "zho_" + - source: "/two_letters_code/android.xml" + translation: "/two_letters_code/%two_letters_code%/android.xml" + languages_mapping: + two_letters_code: + uk: "uk_" + zh-CN: "zh_" diff --git a/tests/e2e/fixtures/language-mapping/language/Chinese Simplified_/android.xml b/tests/e2e/fixtures/language-mapping/language/Chinese Simplified_/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/language/Chinese Simplified_/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language-mapping/language/Ukrainian_/android.xml b/tests/e2e/fixtures/language-mapping/language/Ukrainian_/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/language/Ukrainian_/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/language/android.xml b/tests/e2e/fixtures/language-mapping/language/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/language/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/language-mapping/locale/android.xml b/tests/e2e/fixtures/language-mapping/locale/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/locale/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/language-mapping/locale/uk-UA_/android.xml b/tests/e2e/fixtures/language-mapping/locale/uk-UA_/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/locale/uk-UA_/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/locale/zh-CN_/android.xml b/tests/e2e/fixtures/language-mapping/locale/zh-CN_/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/locale/zh-CN_/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language-mapping/locale_with_underscore/android.xml b/tests/e2e/fixtures/language-mapping/locale_with_underscore/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/locale_with_underscore/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/language-mapping/locale_with_underscore/uk_UA_/android.xml b/tests/e2e/fixtures/language-mapping/locale_with_underscore/uk_UA_/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/locale_with_underscore/uk_UA_/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/locale_with_underscore/zh_CN_/android.xml b/tests/e2e/fixtures/language-mapping/locale_with_underscore/zh_CN_/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/locale_with_underscore/zh_CN_/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language-mapping/osx_code/android.xml b/tests/e2e/fixtures/language-mapping/osx_code/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/osx_code/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/language-mapping/osx_code/uk.lproj_/android.xml b/tests/e2e/fixtures/language-mapping/osx_code/uk.lproj_/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/osx_code/uk.lproj_/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/osx_code/zh-Hans.lproj_/android.xml b/tests/e2e/fixtures/language-mapping/osx_code/zh-Hans.lproj_/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/osx_code/zh-Hans.lproj_/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language-mapping/osx_locale/android.xml b/tests/e2e/fixtures/language-mapping/osx_locale/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/osx_locale/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/language-mapping/osx_locale/uk_/android.xml b/tests/e2e/fixtures/language-mapping/osx_locale/uk_/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/osx_locale/uk_/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/osx_locale/zh-Hans_/android.xml b/tests/e2e/fixtures/language-mapping/osx_locale/zh-Hans_/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/osx_locale/zh-Hans_/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language-mapping/three_letters_code/android.xml b/tests/e2e/fixtures/language-mapping/three_letters_code/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/three_letters_code/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/language-mapping/three_letters_code/ukr_/android.xml b/tests/e2e/fixtures/language-mapping/three_letters_code/ukr_/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/three_letters_code/ukr_/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/three_letters_code/zho_/android.xml b/tests/e2e/fixtures/language-mapping/three_letters_code/zho_/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/three_letters_code/zho_/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language-mapping/two_letters_code/android.xml b/tests/e2e/fixtures/language-mapping/two_letters_code/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/two_letters_code/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/language-mapping/two_letters_code/uk_/android.xml b/tests/e2e/fixtures/language-mapping/two_letters_code/uk_/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/two_letters_code/uk_/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/language-mapping/two_letters_code/zh_/android.xml b/tests/e2e/fixtures/language-mapping/two_letters_code/zh_/android.xml new file mode 100644 index 000000000..b06d1ea52 --- /dev/null +++ b/tests/e2e/fixtures/language-mapping/two_letters_code/zh_/android.xml @@ -0,0 +1,5 @@ + + + 第一行 + 第二行 + diff --git a/tests/e2e/fixtures/language/alt-configs/no-credentials.yml b/tests/e2e/fixtures/language/alt-configs/no-credentials.yml new file mode 100644 index 000000000..39eb135ad --- /dev/null +++ b/tests/e2e/fixtures/language/alt-configs/no-credentials.yml @@ -0,0 +1,2 @@ +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/language/alt-configs/no-project.yml b/tests/e2e/fixtures/language/alt-configs/no-project.yml new file mode 100644 index 000000000..6934e7e6c --- /dev/null +++ b/tests/e2e/fixtures/language/alt-configs/no-project.yml @@ -0,0 +1,3 @@ +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/language/config/crowdin.yml b/tests/e2e/fixtures/language/config/crowdin.yml new file mode 100644 index 000000000..576449444 --- /dev/null +++ b/tests/e2e/fixtures/language/config/crowdin.yml @@ -0,0 +1,4 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/alt-configs/crowdin-original.yml b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/alt-configs/crowdin-original.yml new file mode 100644 index 000000000..086e7c8dc --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/alt-configs/crowdin-original.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.csv" + translation: "/translations/%two_letters_code%/%original_file_name%" + first_line_contains_header: true + scheme: "identifier,source_phrase,it,uk" diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/alt-configs/crowdin-v2.yml b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/alt-configs/crowdin-v2.yml new file mode 100644 index 000000000..7f4dc59e5 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/alt-configs/crowdin-v2.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.csv" + translation: "/translations-v2/%two_letters_code%/%original_file_name%" + first_line_contains_header: true + scheme: "identifier,source_phrase,it,uk" diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/config/crowdin.yml b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/config/crowdin.yml new file mode 100644 index 000000000..086e7c8dc --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.csv" + translation: "/translations/%two_letters_code%/%original_file_name%" + first_line_contains_header: true + scheme: "identifier,source_phrase,it,uk" diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/it/1_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/it/1_multilingual.csv new file mode 100755 index 000000000..ebecdba11 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/it/1_multilingual.csv @@ -0,0 +1,4 @@ +"ident","source","it","uk" +"ident1","first string","prima stringa","перша стрічка" +"ident2","second string","seconda stringa","друга стрічка" +"ident3","third string","terza stringa","третя стрічка" diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/it/2_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/it/2_multilingual.csv new file mode 100755 index 000000000..341b0da8e --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/it/2_multilingual.csv @@ -0,0 +1,3 @@ +"ident","source","it","uk" +"ident1","first string","prima stringa","перша стрічка" +"ident2","second string","second string","second string" diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/uk/1_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/uk/1_multilingual.csv new file mode 100755 index 000000000..ebecdba11 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/uk/1_multilingual.csv @@ -0,0 +1,4 @@ +"ident","source","it","uk" +"ident1","first string","prima stringa","перша стрічка" +"ident2","second string","seconda stringa","друга стрічка" +"ident3","third string","terza stringa","третя стрічка" diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/uk/2_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/uk/2_multilingual.csv new file mode 100755 index 000000000..341b0da8e --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/expected/uk/2_multilingual.csv @@ -0,0 +1,3 @@ +"ident","source","it","uk" +"ident1","first string","prima stringa","перша стрічка" +"ident2","second string","second string","second string" diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/rev2/sources/1_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/rev2/sources/1_multilingual.csv new file mode 100644 index 000000000..c53289223 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/rev2/sources/1_multilingual.csv @@ -0,0 +1,5 @@ +ident,source,it,uk +ident1,first string,, +ident2,second string,, +ident3,third string,, +ident4,forth string,, diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/rev2/sources/2_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/rev2/sources/2_multilingual.csv new file mode 100644 index 000000000..a7d9cc57f --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/rev2/sources/2_multilingual.csv @@ -0,0 +1,3 @@ +ident,source,it,uk +ident1,first string,, +ident2,second string,, diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/sources/1_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/sources/1_multilingual.csv new file mode 100644 index 000000000..cd8854bd1 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/sources/1_multilingual.csv @@ -0,0 +1,4 @@ +ident,source,it,uk +ident1,first string,, +ident2,second string,, +ident3,third string,, diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/sources/2_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/sources/2_multilingual.csv new file mode 100644 index 000000000..a7d9cc57f --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/sources/2_multilingual.csv @@ -0,0 +1,3 @@ +ident,source,it,uk +ident1,first string,, +ident2,second string,, diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/it/1_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/it/1_multilingual.csv new file mode 100644 index 000000000..26ebecf77 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/it/1_multilingual.csv @@ -0,0 +1,4 @@ +ident,source,it,uk +ident1,first string,prima stringa, +ident2,second string,seconda stringa, +ident3,third string,terza stringa, diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/it/2_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/it/2_multilingual.csv new file mode 100644 index 000000000..b42d5ee0f --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/it/2_multilingual.csv @@ -0,0 +1,3 @@ +ident,source,it,uk +ident1,first string,prima stringa, +ident2,second string,, diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/uk/1_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/uk/1_multilingual.csv new file mode 100644 index 000000000..10dea14fe --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/uk/1_multilingual.csv @@ -0,0 +1,4 @@ +ident,source,it,uk +ident1,first string,,перша стрічка +ident2,second string,,друга стрічка +ident3,third string,,третя стрічка diff --git a/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/uk/2_multilingual.csv b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/uk/2_multilingual.csv new file mode 100644 index 000000000..906f1b99e --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv-with-language-placeholder/translations/uk/2_multilingual.csv @@ -0,0 +1,3 @@ +ident,source,it,uk +ident1,first string,,перша стрічка +ident2,second string,, diff --git a/tests/e2e/fixtures/multilingual-csv/alt-configs/branch.yml b/tests/e2e/fixtures/multilingual-csv/alt-configs/branch.yml new file mode 100644 index 000000000..eac772270 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/alt-configs/branch.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "sources/rev1/branch" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/*.csv" + translation: "/%original_file_name%" + first_line_contains_header: true + scheme: "identifier,source_phrase,context,max_length,it,uk" + import_translations: false diff --git a/tests/e2e/fixtures/multilingual-csv/alt-configs/project-root.yml b/tests/e2e/fixtures/multilingual-csv/alt-configs/project-root.yml new file mode 100644 index 000000000..21dbb7234 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/alt-configs/project-root.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "sources/rev1/project-root" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/*.csv" + translation: "/%original_file_name%" + first_line_contains_header: true + scheme: "identifier,source_phrase,context,max_length,it,uk" + import_translations: false diff --git a/tests/e2e/fixtures/multilingual-csv/alt-configs/without-translations.yml b/tests/e2e/fixtures/multilingual-csv/alt-configs/without-translations.yml new file mode 100644 index 000000000..fce646299 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/alt-configs/without-translations.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "sources/rev1" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/without-translations/*.csv" + translation: "/without-translations/%original_file_name%" + first_line_contains_header: true + scheme: "identifier,source_phrase,context,max_length,it,uk" + import_translations: false diff --git a/tests/e2e/fixtures/multilingual-csv/config/crowdin.yml b/tests/e2e/fixtures/multilingual-csv/config/crowdin.yml new file mode 100644 index 000000000..0f187dac2 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/config/crowdin.yml @@ -0,0 +1,11 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "sources/rev1" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/with-translations/*.csv" + translation: "/with-translations/%original_file_name%" + first_line_contains_header: true + scheme: "identifier,source_phrase,context,max_length,it,uk" + import_translations: true diff --git a/tests/e2e/fixtures/multilingual-csv/expected/branch/sample.csv b/tests/e2e/fixtures/multilingual-csv/expected/branch/sample.csv new file mode 100644 index 000000000..2cd3ffc04 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/expected/branch/sample.csv @@ -0,0 +1,8 @@ +"identifier","source","context","max_length","it","uk" +"identifier1","string 1","context 1","20","stringa 1","стрічка 1" +"identifier2","string 2","context 2","20","stringa 2","стрічка 2" +"identifier3","string 3","context 3","20","stringa 3","стрічка 3" +"identifier4","string 4","context 4","20","stringa 4","стрічка 4" +"identifier5","string 5","context 5","20","stringa 5","стрічка 5" +"identifier6","string 6","context 6","20","stringa 6","стрічка 6" +"identifier7","string 7","context 7","20","stringa 7","стрічка 7" diff --git a/tests/e2e/fixtures/multilingual-csv/expected/with-translations/sample.csv b/tests/e2e/fixtures/multilingual-csv/expected/with-translations/sample.csv new file mode 100644 index 000000000..2cd3ffc04 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/expected/with-translations/sample.csv @@ -0,0 +1,8 @@ +"identifier","source","context","max_length","it","uk" +"identifier1","string 1","context 1","20","stringa 1","стрічка 1" +"identifier2","string 2","context 2","20","stringa 2","стрічка 2" +"identifier3","string 3","context 3","20","stringa 3","стрічка 3" +"identifier4","string 4","context 4","20","stringa 4","стрічка 4" +"identifier5","string 5","context 5","20","stringa 5","стрічка 5" +"identifier6","string 6","context 6","20","stringa 6","стрічка 6" +"identifier7","string 7","context 7","20","stringa 7","стрічка 7" diff --git a/tests/e2e/fixtures/multilingual-csv/expected/without-translations/sample.csv b/tests/e2e/fixtures/multilingual-csv/expected/without-translations/sample.csv new file mode 100644 index 000000000..2cd3ffc04 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/expected/without-translations/sample.csv @@ -0,0 +1,8 @@ +"identifier","source","context","max_length","it","uk" +"identifier1","string 1","context 1","20","stringa 1","стрічка 1" +"identifier2","string 2","context 2","20","stringa 2","стрічка 2" +"identifier3","string 3","context 3","20","stringa 3","стрічка 3" +"identifier4","string 4","context 4","20","stringa 4","стрічка 4" +"identifier5","string 5","context 5","20","stringa 5","стрічка 5" +"identifier6","string 6","context 6","20","stringa 6","стрічка 6" +"identifier7","string 7","context 7","20","stringa 7","стрічка 7" diff --git a/tests/e2e/fixtures/multilingual-csv/sources/rev1/branch/sample.csv b/tests/e2e/fixtures/multilingual-csv/sources/rev1/branch/sample.csv new file mode 100755 index 000000000..7720df624 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/sources/rev1/branch/sample.csv @@ -0,0 +1,6 @@ +identifier,source,context,max_length,it,uk +identifier1,string 1,context 1,20,, +identifier2,string 2,context 2,20,, +identifier3,string 3,context 3,20,, +identifier4,string 4,context 4,20,, +identifier5,string 5,context 5,20,, diff --git a/tests/e2e/fixtures/multilingual-csv/sources/rev1/project-root/sample.csv b/tests/e2e/fixtures/multilingual-csv/sources/rev1/project-root/sample.csv new file mode 100755 index 000000000..4d13a7d73 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/sources/rev1/project-root/sample.csv @@ -0,0 +1,6 @@ +identifier,source,context,max_length,it,uk +identifier1,string 1,context 1,20,stringa 1,стрічка 1 +identifier2,string 2,context 2,20,stringa 2,стрічка 2 +identifier3,string 3,context 3,20,stringa 3,стрічка 3 +identifier4,string 4,context 4,20,stringa 4,стрічка 4 +identifier5,string 5,context 5,20,stringa 5,стрічка 5 diff --git a/tests/e2e/fixtures/multilingual-csv/sources/rev1/with-translations/sample.csv b/tests/e2e/fixtures/multilingual-csv/sources/rev1/with-translations/sample.csv new file mode 100755 index 000000000..4d13a7d73 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/sources/rev1/with-translations/sample.csv @@ -0,0 +1,6 @@ +identifier,source,context,max_length,it,uk +identifier1,string 1,context 1,20,stringa 1,стрічка 1 +identifier2,string 2,context 2,20,stringa 2,стрічка 2 +identifier3,string 3,context 3,20,stringa 3,стрічка 3 +identifier4,string 4,context 4,20,stringa 4,стрічка 4 +identifier5,string 5,context 5,20,stringa 5,стрічка 5 diff --git a/tests/e2e/fixtures/multilingual-csv/sources/rev1/without-translations/sample.csv b/tests/e2e/fixtures/multilingual-csv/sources/rev1/without-translations/sample.csv new file mode 100755 index 000000000..4d13a7d73 --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/sources/rev1/without-translations/sample.csv @@ -0,0 +1,6 @@ +identifier,source,context,max_length,it,uk +identifier1,string 1,context 1,20,stringa 1,стрічка 1 +identifier2,string 2,context 2,20,stringa 2,стрічка 2 +identifier3,string 3,context 3,20,stringa 3,стрічка 3 +identifier4,string 4,context 4,20,stringa 4,стрічка 4 +identifier5,string 5,context 5,20,stringa 5,стрічка 5 diff --git a/tests/e2e/fixtures/multilingual-csv/sources/rev2/branch/sample.csv b/tests/e2e/fixtures/multilingual-csv/sources/rev2/branch/sample.csv new file mode 100755 index 000000000..e78163b0a --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/sources/rev2/branch/sample.csv @@ -0,0 +1,8 @@ +identifier,source,context,max_length,it,uk +identifier1,string 1,context 1,20,, +identifier2,string 2,context 2,20,, +identifier3,string 3,context 3,20,, +identifier4,string 4,context 4,20,, +identifier5,string 5,context 5,20,, +identifier6,string 6,context 6,20,, +identifier7,string 7,context 7,20,, diff --git a/tests/e2e/fixtures/multilingual-csv/sources/rev2/with-translations/sample.csv b/tests/e2e/fixtures/multilingual-csv/sources/rev2/with-translations/sample.csv new file mode 100755 index 000000000..4bfb1752b --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/sources/rev2/with-translations/sample.csv @@ -0,0 +1,8 @@ +identifier,source,context,max_length,it,uk +identifier1,string 1,context 1,20,stringa 1,стрічка 1 +identifier2,string 2,context 2,20,stringa 2,стрічка 2 +identifier3,string 3,context 3,20,stringa 3,стрічка 3 +identifier4,string 4,context 4,20,stringa 4,стрічка 4 +identifier5,string 5,context 5,20,stringa 5,стрічка 5 +identifier6,string 6,context 6,20,stringa 6,стрічка 6 +identifier7,string 7,context 7,20,stringa 7,стрічка 7 diff --git a/tests/e2e/fixtures/multilingual-csv/sources/rev2/without-translations/sample.csv b/tests/e2e/fixtures/multilingual-csv/sources/rev2/without-translations/sample.csv new file mode 100755 index 000000000..4bfb1752b --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/sources/rev2/without-translations/sample.csv @@ -0,0 +1,8 @@ +identifier,source,context,max_length,it,uk +identifier1,string 1,context 1,20,stringa 1,стрічка 1 +identifier2,string 2,context 2,20,stringa 2,стрічка 2 +identifier3,string 3,context 3,20,stringa 3,стрічка 3 +identifier4,string 4,context 4,20,stringa 4,стрічка 4 +identifier5,string 5,context 5,20,stringa 5,стрічка 5 +identifier6,string 6,context 6,20,stringa 6,стрічка 6 +identifier7,string 7,context 7,20,stringa 7,стрічка 7 diff --git a/tests/e2e/fixtures/multilingual-csv/sources/rev3/branch/sample.csv b/tests/e2e/fixtures/multilingual-csv/sources/rev3/branch/sample.csv new file mode 100755 index 000000000..4bfb1752b --- /dev/null +++ b/tests/e2e/fixtures/multilingual-csv/sources/rev3/branch/sample.csv @@ -0,0 +1,8 @@ +identifier,source,context,max_length,it,uk +identifier1,string 1,context 1,20,stringa 1,стрічка 1 +identifier2,string 2,context 2,20,stringa 2,стрічка 2 +identifier3,string 3,context 3,20,stringa 3,стрічка 3 +identifier4,string 4,context 4,20,stringa 4,стрічка 4 +identifier5,string 5,context 5,20,stringa 5,стрічка 5 +identifier6,string 6,context 6,20,stringa 6,стрічка 6 +identifier7,string 7,context 7,20,stringa 7,стрічка 7 diff --git a/tests/e2e/fixtures/project/config/crowdin.yml b/tests/e2e/fixtures/project/config/crowdin.yml new file mode 100644 index 000000000..576449444 --- /dev/null +++ b/tests/e2e/fixtures/project/config/crowdin.yml @@ -0,0 +1,4 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/screenshot/config/crowdin.yml b/tests/e2e/fixtures/screenshot/config/crowdin.yml new file mode 100644 index 000000000..d6cd2475c --- /dev/null +++ b/tests/e2e/fixtures/screenshot/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/screenshot/images/not-an-image.txt b/tests/e2e/fixtures/screenshot/images/not-an-image.txt new file mode 100644 index 000000000..283e5e936 --- /dev/null +++ b/tests/e2e/fixtures/screenshot/images/not-an-image.txt @@ -0,0 +1 @@ +not an image diff --git a/tests/e2e/fixtures/screenshot/images/screenshot.png b/tests/e2e/fixtures/screenshot/images/screenshot.png new file mode 100644 index 000000000..8d80d8eeb Binary files /dev/null and b/tests/e2e/fixtures/screenshot/images/screenshot.png differ diff --git a/tests/e2e/fixtures/screenshot/images/second.png b/tests/e2e/fixtures/screenshot/images/second.png new file mode 100644 index 000000000..bd8c451b6 Binary files /dev/null and b/tests/e2e/fixtures/screenshot/images/second.png differ diff --git a/tests/e2e/fixtures/screenshot/sources/1_android.xml b/tests/e2e/fixtures/screenshot/sources/1_android.xml new file mode 100644 index 000000000..9f4263e02 --- /dev/null +++ b/tests/e2e/fixtures/screenshot/sources/1_android.xml @@ -0,0 +1,5 @@ + + + first string source file1 + second string source file1 + diff --git a/tests/e2e/fixtures/simple-csv/alt-configs/invalid-scheme.yml b/tests/e2e/fixtures/simple-csv/alt-configs/invalid-scheme.yml new file mode 100644 index 000000000..ce7f914e6 --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/alt-configs/invalid-scheme.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/files/*.csv" + translation: "/sources/files/%two_letters_code%/%original_file_name%" + first_line_contains_header: true + scheme: "identifier,source_phrase,context,max_length" diff --git a/tests/e2e/fixtures/simple-csv/config/crowdin.yml b/tests/e2e/fixtures/simple-csv/config/crowdin.yml new file mode 100644 index 000000000..7af2c7f61 --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/files/*.csv" + translation: "/sources/files/%two_letters_code%/%original_file_name%" + first_line_contains_header: true + scheme: "identifier, source_phrase, context, max_length, translation" diff --git a/tests/e2e/fixtures/simple-csv/expected/it/1_simple.csv b/tests/e2e/fixtures/simple-csv/expected/it/1_simple.csv new file mode 100644 index 000000000..d4c524cfd --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/expected/it/1_simple.csv @@ -0,0 +1,10 @@ +"ident","source","context","max_length","" +"ident1","file 1 string 1","context 1","20","file 1 stringa 1" +"ident2","file 1 string 2","context 2","20","file 1 stringa 2" +"ident3","file 1 string 3","context 3","20","file 1 stringa 3" +"ident4","file 1 string 4","context 4","20","file 1 stringa 4" +"ident5","file 1 string 5","context 5","20","file 1 stringa 5" +"ident6","file 1 string 6","context 6","20","file 1 string 6" +"ident7","file 1 string 7","context 7","20","file 1 string 7" +"ident8","file 1 string 8","context 8","20","file 1 string 8" +"ident9","file 1 string 9","context 9","10","file 1 string 9" diff --git a/tests/e2e/fixtures/simple-csv/expected/it/2_simple.csv b/tests/e2e/fixtures/simple-csv/expected/it/2_simple.csv new file mode 100644 index 000000000..2414dad2e --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/expected/it/2_simple.csv @@ -0,0 +1,10 @@ +"ident","source","context","max_length","it","uk" +"ident1","file 2 string 1","context 1","20","file 2 string 1","" +"ident2","file 2 string 2","context 2","20","file 2 string 2","" +"ident3","file 2 string 3","context 3","20","file 2 string 3","" +"ident4","file 2 string 4","context 4","20","file 2 string 4","" +"ident5","file 2 string 5","context 5","20","file 2 string 5","" +"ident6","file 2 string 6","context 6","20","file 2 string 6","" +"ident7","file 2 string 7","context 7","20","file 2 string 7","" +"ident8","file 2 string 8","context 8","20","file 2 string 8","" +"ident9","file 2 string 9","context 9","20","file 2 string 9","" diff --git a/tests/e2e/fixtures/simple-csv/expected/uk/1_simple.csv b/tests/e2e/fixtures/simple-csv/expected/uk/1_simple.csv new file mode 100644 index 000000000..4c02a4115 --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/expected/uk/1_simple.csv @@ -0,0 +1,10 @@ +"ident","source","context","max_length","" +"ident1","file 1 string 1","context 1","20","файл 1 стрічка 1" +"ident2","file 1 string 2","context 2","20","файл 1 стрічка 2" +"ident3","file 1 string 3","context 3","20","файл 1 стрічка 3" +"ident4","file 1 string 4","context 4","20","файл 1 стрічка 4" +"ident5","file 1 string 5","context 5","20","файл 1 стрічка 5" +"ident6","file 1 string 6","context 6","20","файл 1 стрічка 6" +"ident7","file 1 string 7","context 7","20","файл 1 стрічка 7" +"ident8","file 1 string 8","context 8","20","файл 1 стрічка 8" +"ident9","file 1 string 9","context 9","10","file 1 string 9" diff --git a/tests/e2e/fixtures/simple-csv/expected/uk/2_simple.csv b/tests/e2e/fixtures/simple-csv/expected/uk/2_simple.csv new file mode 100644 index 000000000..2414dad2e --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/expected/uk/2_simple.csv @@ -0,0 +1,10 @@ +"ident","source","context","max_length","it","uk" +"ident1","file 2 string 1","context 1","20","file 2 string 1","" +"ident2","file 2 string 2","context 2","20","file 2 string 2","" +"ident3","file 2 string 3","context 3","20","file 2 string 3","" +"ident4","file 2 string 4","context 4","20","file 2 string 4","" +"ident5","file 2 string 5","context 5","20","file 2 string 5","" +"ident6","file 2 string 6","context 6","20","file 2 string 6","" +"ident7","file 2 string 7","context 7","20","file 2 string 7","" +"ident8","file 2 string 8","context 8","20","file 2 string 8","" +"ident9","file 2 string 9","context 9","20","file 2 string 9","" diff --git a/tests/e2e/fixtures/simple-csv/sources/files/1_simple.csv b/tests/e2e/fixtures/simple-csv/sources/files/1_simple.csv new file mode 100644 index 000000000..1fb22f163 --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/sources/files/1_simple.csv @@ -0,0 +1,10 @@ +ident,source,context,max_length,translation +ident1,file 1 string 1,context 1,20,file 1 string 1 +ident2,file 1 string 2,context 2,20,file 1 string 2 +ident3,file 1 string 3,context 3,20,file 1 string 3 +ident4,file 1 string 4,context 4,20,file 1 string 4 +ident5,file 1 string 5,context 5,20,file 1 string 5 +ident6,file 1 string 6,context 6,20,file 1 string 6 +ident7,file 1 string 7,context 7,20,file 1 string 7 +ident8,file 1 string 8,context 8,20,file 1 string 8 +ident9,file 1 string 9,context 9,10,file 1 string 9 diff --git a/tests/e2e/fixtures/simple-csv/sources/files/2_simple.csv b/tests/e2e/fixtures/simple-csv/sources/files/2_simple.csv new file mode 100644 index 000000000..2d9df0e1a --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/sources/files/2_simple.csv @@ -0,0 +1,10 @@ +ident,source,context,max_length,translation +ident1,file 2 string 1,context 1,20, +ident2,file 2 string 2,context 2,20, +ident3,file 2 string 3,context 3,20, +ident4,file 2 string 4,context 4,20, +ident5,file 2 string 5,context 5,20, +ident6,file 2 string 6,context 6,20, +ident7,file 2 string 7,context 7,20, +ident8,file 2 string 8,context 8,20, +ident9,file 2 string 9,context 9,20, diff --git a/tests/e2e/fixtures/simple-csv/sources/files/it/1_simple.csv b/tests/e2e/fixtures/simple-csv/sources/files/it/1_simple.csv new file mode 100644 index 000000000..71199094b --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/sources/files/it/1_simple.csv @@ -0,0 +1,10 @@ +ident,source,context,max_length,translation +ident1,file 1 string 1,context 1,20,file 1 stringa 1 +ident2,file 1 string 2,context 2,20,file 1 stringa 2 +ident3,file 1 string 3,context 3,20,file 1 stringa 3 +ident4,file 1 string 4,context 4,20,file 1 stringa 4 +ident5,file 1 string 5,context 5,20,file 1 stringa 5 +ident6,file 1 string 6,context 6,20,file 1 string 6 +ident7,file 1 string 7,context 7,20,file 1 string 7 +ident8,file 1 string 8,context 8,20,file 1 string 8 +ident9,file 1 string 9,context 9,10,file 1 string 9 diff --git a/tests/e2e/fixtures/simple-csv/sources/files/it/2_simple.csv b/tests/e2e/fixtures/simple-csv/sources/files/it/2_simple.csv new file mode 100644 index 000000000..2d9df0e1a --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/sources/files/it/2_simple.csv @@ -0,0 +1,10 @@ +ident,source,context,max_length,translation +ident1,file 2 string 1,context 1,20, +ident2,file 2 string 2,context 2,20, +ident3,file 2 string 3,context 3,20, +ident4,file 2 string 4,context 4,20, +ident5,file 2 string 5,context 5,20, +ident6,file 2 string 6,context 6,20, +ident7,file 2 string 7,context 7,20, +ident8,file 2 string 8,context 8,20, +ident9,file 2 string 9,context 9,20, diff --git a/tests/e2e/fixtures/simple-csv/sources/files/uk/1_simple.csv b/tests/e2e/fixtures/simple-csv/sources/files/uk/1_simple.csv new file mode 100644 index 000000000..36a8b0471 --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/sources/files/uk/1_simple.csv @@ -0,0 +1,10 @@ +ident,source,context,max_length,translation +ident1,file 1 string 1,context 1,20,файл 1 стрічка 1 +ident2,file 1 string 2,context 2,20,файл 1 стрічка 2 +ident3,file 1 string 3,context 3,20,файл 1 стрічка 3 +ident4,file 1 string 4,context 4,20,файл 1 стрічка 4 +ident5,file 1 string 5,context 5,20,файл 1 стрічка 5 +ident6,file 1 string 6,context 6,20,файл 1 стрічка 6 +ident7,file 1 string 7,context 7,20,файл 1 стрічка 7 +ident8,file 1 string 8,context 8,20,файл 1 стрічка 8 +ident9,file 1 string 9,context 9,10,файл 1 стрічка 9 diff --git a/tests/e2e/fixtures/simple-csv/sources/files/uk/2_simple.csv b/tests/e2e/fixtures/simple-csv/sources/files/uk/2_simple.csv new file mode 100644 index 000000000..2d9df0e1a --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/sources/files/uk/2_simple.csv @@ -0,0 +1,10 @@ +ident,source,context,max_length,translation +ident1,file 2 string 1,context 1,20, +ident2,file 2 string 2,context 2,20, +ident3,file 2 string 3,context 3,20, +ident4,file 2 string 4,context 4,20, +ident5,file 2 string 5,context 5,20, +ident6,file 2 string 6,context 6,20, +ident7,file 2 string 7,context 7,20, +ident8,file 2 string 8,context 8,20, +ident9,file 2 string 9,context 9,20, diff --git a/tests/e2e/fixtures/simple-csv/sources_rev2/files/1_simple.csv b/tests/e2e/fixtures/simple-csv/sources_rev2/files/1_simple.csv new file mode 100644 index 000000000..421dc1d3a --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/sources_rev2/files/1_simple.csv @@ -0,0 +1,10 @@ +ident,source,context,max_length +ident1,file 1 string 1,context 1,20 +ident2,file 1 string 2,context 2,20 +ident3,file 1 string 3,context 3,20 +ident4,file 1 string 4,context 4,20 +ident5,file 1 string 5,context 5,20 +ident6,file 1 string 6,context 6,20 +ident7,file 1 string 7,context 7,20 +ident8,file 1 string 8,context 8,20 +ident9,file 1 string 9,context 9,10 diff --git a/tests/e2e/fixtures/simple-csv/sources_rev2/files/2_simple.csv b/tests/e2e/fixtures/simple-csv/sources_rev2/files/2_simple.csv new file mode 100644 index 000000000..3629e9744 --- /dev/null +++ b/tests/e2e/fixtures/simple-csv/sources_rev2/files/2_simple.csv @@ -0,0 +1,10 @@ +ident,source,context,max_length,it,uk +ident1,file 2 string 1,context 1,20,, +ident2,file 2 string 2,context 2,20,, +ident3,file 2 string 3,context 3,20,, +ident4,file 2 string 4,context 4,20,, +ident5,file 2 string 5,context 5,20,, +ident6,file 2 string 6,context 6,20,, +ident7,file 2 string 7,context 7,20,, +ident8,file 2 string 8,context 8,20,, +ident9,file 2 string 9,context 9,20,, diff --git a/tests/e2e/fixtures/status/config/crowdin.yml b/tests/e2e/fixtures/status/config/crowdin.yml new file mode 100644 index 000000000..0a9adc294 --- /dev/null +++ b/tests/e2e/fixtures/status/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/**/*.xml" + translation: "/translations/%two_letters_code%/**/%original_file_name%" diff --git a/tests/e2e/fixtures/status/sources/1_android.xml b/tests/e2e/fixtures/status/sources/1_android.xml new file mode 100644 index 000000000..b69a70c02 --- /dev/null +++ b/tests/e2e/fixtures/status/sources/1_android.xml @@ -0,0 +1,8 @@ + + + first string source file1 + second string source file1 + third string source file1 + fourth string source file1 + fifth string source file1 + diff --git a/tests/e2e/fixtures/status/sources/nested/2_android.xml b/tests/e2e/fixtures/status/sources/nested/2_android.xml new file mode 100644 index 000000000..53bc1c371 --- /dev/null +++ b/tests/e2e/fixtures/status/sources/nested/2_android.xml @@ -0,0 +1,6 @@ + + + first string source file2 + second string source file2 + third string source file2 + diff --git a/tests/e2e/fixtures/status/translations/uk/1_android.xml b/tests/e2e/fixtures/status/translations/uk/1_android.xml new file mode 100644 index 000000000..55c321213 --- /dev/null +++ b/tests/e2e/fixtures/status/translations/uk/1_android.xml @@ -0,0 +1,8 @@ + + + перший рядок файлу1 + другий рядок файлу1 + третій рядок файлу1 + четвертий рядок файлу1 + п'ятий рядок файлу1 + diff --git a/tests/e2e/fixtures/status/translations/uk/nested/2_android.xml b/tests/e2e/fixtures/status/translations/uk/nested/2_android.xml new file mode 100644 index 000000000..796b18f56 --- /dev/null +++ b/tests/e2e/fixtures/status/translations/uk/nested/2_android.xml @@ -0,0 +1,6 @@ + + + перший рядок файлу2 + другий рядок файлу2 + третій рядок файлу2 + diff --git a/tests/e2e/fixtures/string/alt-configs/default.yml b/tests/e2e/fixtures/string/alt-configs/default.yml new file mode 100644 index 000000000..cdb014a06 --- /dev/null +++ b/tests/e2e/fixtures/string/alt-configs/default.yml @@ -0,0 +1,13 @@ +# Identical to config/crowdin.yml - used to restore the full (token + project id + two file +# entries) configuration after the `without-token.yml` test switches to a config that lacks +# `api_token`/`project_id`. +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + - source: "/sources/text.txt" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/string/alt-configs/without-token.yml b/tests/e2e/fixtures/string/alt-configs/without-token.yml new file mode 100644 index 000000000..fe73f5404 --- /dev/null +++ b/tests/e2e/fixtures/string/alt-configs/without-token.yml @@ -0,0 +1,7 @@ +# No `api_token` (and no `project_id`) - the suite supplies both via `-T`/`-i` instead. +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/string/config/crowdin.yml b/tests/e2e/fixtures/string/config/crowdin.yml new file mode 100644 index 000000000..5861dba9c --- /dev/null +++ b/tests/e2e/fixtures/string/config/crowdin.yml @@ -0,0 +1,10 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false +files: + - source: "/sources/android.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" + - source: "/sources/text.txt" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/string/sources/android.xml b/tests/e2e/fixtures/string/sources/android.xml new file mode 100644 index 000000000..0bd7c65fa --- /dev/null +++ b/tests/e2e/fixtures/string/sources/android.xml @@ -0,0 +1,7 @@ + + + first string + second string + first string source` with tag + first string source' with quotes + diff --git a/tests/e2e/fixtures/string/sources/text.txt b/tests/e2e/fixtures/string/sources/text.txt new file mode 100644 index 000000000..2fcdcd4d8 --- /dev/null +++ b/tests/e2e/fixtures/string/sources/text.txt @@ -0,0 +1 @@ +First text string. Second text string. diff --git a/tests/e2e/fixtures/task/config/crowdin.yml b/tests/e2e/fixtures/task/config/crowdin.yml new file mode 100644 index 000000000..d6cd2475c --- /dev/null +++ b/tests/e2e/fixtures/task/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/task/sources/1_android.xml b/tests/e2e/fixtures/task/sources/1_android.xml new file mode 100644 index 000000000..dfb4dcb51 --- /dev/null +++ b/tests/e2e/fixtures/task/sources/1_android.xml @@ -0,0 +1,6 @@ + + + first string source file1 + second string source file1 + third string source file1 + diff --git a/tests/e2e/fixtures/task/sources/2_android.xml b/tests/e2e/fixtures/task/sources/2_android.xml new file mode 100644 index 000000000..8c483f355 --- /dev/null +++ b/tests/e2e/fixtures/task/sources/2_android.xml @@ -0,0 +1,5 @@ + + + first string source file2 + second string source file2 + diff --git a/tests/e2e/fixtures/task/translations/it/1_android.xml b/tests/e2e/fixtures/task/translations/it/1_android.xml new file mode 100644 index 000000000..399a62c1d --- /dev/null +++ b/tests/e2e/fixtures/task/translations/it/1_android.xml @@ -0,0 +1,6 @@ + + + prima stringa file1 + seconda stringa file1 + terza stringa file1 + diff --git a/tests/e2e/fixtures/task/translations/it/2_android.xml b/tests/e2e/fixtures/task/translations/it/2_android.xml new file mode 100644 index 000000000..f59c20f74 --- /dev/null +++ b/tests/e2e/fixtures/task/translations/it/2_android.xml @@ -0,0 +1,5 @@ + + + prima stringa file2 + seconda stringa file2 + diff --git a/tests/e2e/fixtures/tm/alt-configs/without-token.yml b/tests/e2e/fixtures/tm/alt-configs/without-token.yml new file mode 100644 index 000000000..a7e5b760c --- /dev/null +++ b/tests/e2e/fixtures/tm/alt-configs/without-token.yml @@ -0,0 +1,8 @@ +base_path: "." +base_url: "https://api.crowdin.com" + +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/tm/config/crowdin.yml b/tests/e2e/fixtures/tm/config/crowdin.yml new file mode 100644 index 000000000..6934e7e6c --- /dev/null +++ b/tests/e2e/fixtures/tm/config/crowdin.yml @@ -0,0 +1,3 @@ +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" diff --git a/tests/e2e/fixtures/tm/expected/simple-tm.csv b/tests/e2e/fixtures/tm/expected/simple-tm.csv new file mode 100644 index 000000000..93ca890e1 --- /dev/null +++ b/tests/e2e/fixtures/tm/expected/simple-tm.csv @@ -0,0 +1,5 @@ +ar,de,en,uk,zh-CN +"إضافة و إزالة البرامج",Software,Install/Uninstall,Встановлення/Видалення,安装/卸载 +"لتثبيت برنامج جديد من قرص مرن أو صلب أو مُدمجللبدء اضغط على زر التثبيت","Um ein Programm von Diskette, CD-ROM oder Festplatte zu installieren","To install a new program from a floppy disk, CD-ROM drive, or your hard drive, click Install.","Щоб встановити нову програму з дискети, CD-ROM, чи жорсткого диску, натисніть Встановити.",要从软盘,光盘或硬盘安装新程序,请按'安装'。 +&تثبيت...,&Installieren...,&Install...,&Встановити...,安装(&I)... +"البرمجيات التالية يمكن حذفها بصورة تلقائية . لحذف برنامجمُثبّت أو تعديله ، ما عليك إلا اختياره من القائمة و الضغط علىزر التّعديل أو الإزالة .","Folgende Programme können automatisch entfernt werden. Um ein Programm zu entfernen oder um installierte Komponenten zu ändern, wählen Sie es aus der Liste aus und klicken Sie auf Ändern/Entfernen.","The following software can be automatically removed. To remove a program or to modify its installed components, select it from the list and click Modify/Remove.","Дане ПЗ може бути видалене автоматично. Щоб видалити програму чи змінити її склад, виберіть її зі списку та натисніть Змінити/Видалити.","下列软件可以自动卸载。 按'修改/删除'可卸载选定程序或者调整已安装部件。" diff --git a/tests/e2e/fixtures/tm/expected/simple-tm.tmx b/tests/e2e/fixtures/tm/expected/simple-tm.tmx new file mode 100755 index 000000000..b7b08b62b --- /dev/null +++ b/tests/e2e/fixtures/tm/expected/simple-tm.tmx @@ -0,0 +1,95 @@ + + + +
+ + + + 5d5c53a5e4dd9606f974638c68613ca1 + Install/Uninstall + + + 5d5c53a5e4dd9606f974638c68613ca1 + إضافة و إزالة البرامج + + + 5d5c53a5e4dd9606f974638c68613ca1 + Software + + + 5d5c53a5e4dd9606f974638c68613ca1 + Встановлення/Видалення + + + 5d5c53a5e4dd9606f974638c68613ca1 + 安装/卸载 + + + + + acd31192bbcc9648a472f2e48a3d1853 + To install a new program from a floppy disk, CD-ROM drive, or your hard drive, click Install. + + + acd31192bbcc9648a472f2e48a3d1853 + لتثبيت برنامج جديد من قرص مرن أو صلب أو مُدمجللبدء اضغط على زر التثبيت + + + acd31192bbcc9648a472f2e48a3d1853 + Um ein Programm von Diskette, CD-ROM oder Festplatte zu installieren, klicken Sie Installieren. + + + acd31192bbcc9648a472f2e48a3d1853 + Щоб встановити нову програму з дискети, CD-ROM, чи жорсткого диску, натисніть Встановити. + + + acd31192bbcc9648a472f2e48a3d1853 + 要从软盘,光盘或硬盘安装新程序,请按'安装'。 + + + + + 3c2ba65bf0031e4a9627dc60ab10977f + &Install... + + + 3c2ba65bf0031e4a9627dc60ab10977f + &تثبيت... + + + 3c2ba65bf0031e4a9627dc60ab10977f + &Installieren... + + + 3c2ba65bf0031e4a9627dc60ab10977f + &Встановити... + + + 3c2ba65bf0031e4a9627dc60ab10977f + 安装(&I)... + + + + + 18ed9a727f9c3052d0d2c9791d2eb518 + The following software can be automatically removed. To remove a program or to modify its installed components, select it from the list and click Modify/Remove. + + + 18ed9a727f9c3052d0d2c9791d2eb518 + البرمجيات التالية يمكن حذفها بصورة تلقائية . لحذف برنامجمُثبّت أو تعديله ، ما عليك إلا اختياره من القائمة و الضغط علىزر التّعديل أو الإزالة . + + + 18ed9a727f9c3052d0d2c9791d2eb518 + Folgende Programme können automatisch entfernt werden. Um ein Programm zu entfernen oder um installierte Komponenten zu ändern, wählen Sie es aus der Liste aus und klicken Sie auf Ändern/Entfernen. + + + 18ed9a727f9c3052d0d2c9791d2eb518 + Дане ПЗ може бути видалене автоматично. Щоб видалити програму чи змінити її склад, виберіть її зі списку та натисніть Змінити/Видалити. + + + 18ed9a727f9c3052d0d2c9791d2eb518 + 下列软件可以自动卸载。 按'修改/删除'可卸载选定程序或者调整已安装部件。 + + + + diff --git a/tests/e2e/fixtures/tm/expected/simple-tm.xlsx b/tests/e2e/fixtures/tm/expected/simple-tm.xlsx new file mode 100755 index 000000000..1cdc28d36 Binary files /dev/null and b/tests/e2e/fixtures/tm/expected/simple-tm.xlsx differ diff --git a/tests/e2e/fixtures/tm/expected/simple-tm_en-uk.tmx b/tests/e2e/fixtures/tm/expected/simple-tm_en-uk.tmx new file mode 100644 index 000000000..f677f13b2 --- /dev/null +++ b/tests/e2e/fixtures/tm/expected/simple-tm_en-uk.tmx @@ -0,0 +1,47 @@ + + + +
+ + + + 5d5c53a5e4dd9606f974638c68613ca1 + Install/Uninstall + + + 5d5c53a5e4dd9606f974638c68613ca1 + Встановлення/Видалення + + + + + acd31192bbcc9648a472f2e48a3d1853 + To install a new program from a floppy disk, CD-ROM drive, or your hard drive, click Install. + + + acd31192bbcc9648a472f2e48a3d1853 + Щоб встановити нову програму з дискети, CD-ROM, чи жорсткого диску, натисніть Встановити. + + + + + 3c2ba65bf0031e4a9627dc60ab10977f + &Install... + + + 3c2ba65bf0031e4a9627dc60ab10977f + &Встановити... + + + + + 18ed9a727f9c3052d0d2c9791d2eb518 + The following software can be automatically removed. To remove a program or to modify its installed components, select it from the list and click Modify/Remove. + + + 18ed9a727f9c3052d0d2c9791d2eb518 + Дане ПЗ може бути видалене автоматично. Щоб видалити програму чи змінити її склад, виберіть її зі списку та натисніть Змінити/Видалити. + + + + diff --git a/tests/e2e/fixtures/tm/sources/extra-tm.tmx b/tests/e2e/fixtures/tm/sources/extra-tm.tmx new file mode 100644 index 000000000..7f3dc77f6 --- /dev/null +++ b/tests/e2e/fixtures/tm/sources/extra-tm.tmx @@ -0,0 +1,23 @@ + + + +
+ + + + Restart the computer to finish. + + + Перезавантажте комп'ютер, щоб завершити. + + + + + Choose a destination folder. + + + Виберіть теку призначення. + + + + diff --git a/tests/e2e/fixtures/tm/sources/simple-tm.csv b/tests/e2e/fixtures/tm/sources/simple-tm.csv new file mode 100755 index 000000000..1c6261914 --- /dev/null +++ b/tests/e2e/fixtures/tm/sources/simple-tm.csv @@ -0,0 +1,5 @@ +,ar,de,en,uk,Zh-CN +d3b1b7eed0c78548d4249eda6cd85536119250,إضافة و إزالة البرامج,Software,Install/Uninstall,Встановлення/Видалення,安装/卸载 +107d96a4ad4d60e4a9951050468d399f119251,لتثبيت برنامج جديد من قرص مرن أو صلب أو مُدمجللبدء اضغط على زر التثبيت,"Um ein Programm von Diskette, CD-ROM oder Festplatte zu installieren","To install a new program from a floppy disk, CD-ROM drive, or your hard drive, click Install.","Щоб встановити нову програму з дискети, CD-ROM, чи жорсткого диску, натисніть Встановити.",要从软盘,光盘或硬盘安装新程序,请按'安装'。 +f1a371ec36c3e7376c2d75ab6b1e5a26119252,&تثبيت...,&Installieren...,&Install...,&Встановити...,安装(&I)... +ae4e54badbfda78b679ee94b275acc8d119253,البرمجيات التالية يمكن حذفها بصورة تلقائية . لحذف برنامجمُثبّت أو تعديله ، ما عليك إلا اختياره من القائمة و الضغط علىزر التّعديل أو الإزالة .,"Folgende Programme können automatisch entfernt werden. Um ein Programm zu entfernen oder um installierte Komponenten zu ändern, wählen Sie es aus der Liste aus und klicken Sie auf Ändern/Entfernen.","The following software can be automatically removed. To remove a program or to modify its installed components, select it from the list and click Modify/Remove.","Дане ПЗ може бути видалене автоматично. Щоб видалити програму чи змінити її склад, виберіть її зі списку та натисніть Змінити/Видалити.",下列软件可以自动卸载。 按'修改/删除'可卸载选定程序或者调整已安装部件。 diff --git a/tests/e2e/fixtures/tm/sources/simple-tm.tmx b/tests/e2e/fixtures/tm/sources/simple-tm.tmx new file mode 100755 index 000000000..c7b2b5441 --- /dev/null +++ b/tests/e2e/fixtures/tm/sources/simple-tm.tmx @@ -0,0 +1,95 @@ + + + +
+ + + + 5d5c53a5e4dd9606f974638c68613ca1 + إضافة و إزالة البرامج + + + 5d5c53a5e4dd9606f974638c68613ca1 + Software + + + 5d5c53a5e4dd9606f974638c68613ca1 + Install/Uninstall + + + 5d5c53a5e4dd9606f974638c68613ca1 + Встановлення/Видалення + + + 5d5c53a5e4dd9606f974638c68613ca1 + 安装/卸载 + + + + + acd31192bbcc9648a472f2e48a3d1853 + لتثبيت برنامج جديد من قرص مرن أو صلب أو مُدمجللبدء اضغط على زر التثبيت + + + acd31192bbcc9648a472f2e48a3d1853 + Um ein Programm von Diskette, CD-ROM oder Festplatte zu installieren, klicken Sie Installieren. + + + acd31192bbcc9648a472f2e48a3d1853 + To install a new program from a floppy disk, CD-ROM drive, or your hard drive, click Install. + + + acd31192bbcc9648a472f2e48a3d1853 + Щоб встановити нову програму з дискети, CD-ROM, чи жорсткого диску, натисніть Встановити. + + + acd31192bbcc9648a472f2e48a3d1853 + 要从软盘,光盘或硬盘安装新程序,请按'安装'。 + + + + + 3c2ba65bf0031e4a9627dc60ab10977f + &تثبيت... + + + 3c2ba65bf0031e4a9627dc60ab10977f + &Installieren... + + + 3c2ba65bf0031e4a9627dc60ab10977f + &Install... + + + 3c2ba65bf0031e4a9627dc60ab10977f + &Встановити... + + + 3c2ba65bf0031e4a9627dc60ab10977f + 安装(&I)... + + + + + 18ed9a727f9c3052d0d2c9791d2eb518 + البرمجيات التالية يمكن حذفها بصورة تلقائية . لحذف برنامجمُثبّت أو تعديله ، ما عليك إلا اختياره من القائمة و الضغط علىزر التّعديل أو الإزالة . + + + 18ed9a727f9c3052d0d2c9791d2eb518 + Folgende Programme können automatisch entfernt werden. Um ein Programm zu entfernen oder um installierte Komponenten zu ändern, wählen Sie es aus der Liste aus und klicken Sie auf Ändern/Entfernen. + + + 18ed9a727f9c3052d0d2c9791d2eb518 + The following software can be automatically removed. To remove a program or to modify its installed components, select it from the list and click Modify/Remove. + + + 18ed9a727f9c3052d0d2c9791d2eb518 + Дане ПЗ може бути видалене автоматично. Щоб видалити програму чи змінити її склад, виберіть її зі списку та натисніть Змінити/Видалити. + + + 18ed9a727f9c3052d0d2c9791d2eb518 + 下列软件可以自动卸载。 按'修改/删除'可卸载选定程序或者调整已安装部件。 + + + + diff --git a/tests/e2e/fixtures/tm/sources/simple-tm.xlsx b/tests/e2e/fixtures/tm/sources/simple-tm.xlsx new file mode 100755 index 000000000..f0247abc4 Binary files /dev/null and b/tests/e2e/fixtures/tm/sources/simple-tm.xlsx differ diff --git a/tests/e2e/fixtures/tm/sources/unsupported.txt b/tests/e2e/fixtures/tm/sources/unsupported.txt new file mode 100644 index 000000000..eefd9719c --- /dev/null +++ b/tests/e2e/fixtures/tm/sources/unsupported.txt @@ -0,0 +1 @@ +not a translation memory diff --git a/tests/e2e/fixtures/translation-patterns/android_code/android.xml b/tests/e2e/fixtures/translation-patterns/android_code/android.xml new file mode 100644 index 000000000..8354baee4 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/android_code/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/android_code/uk-rUA/android.xml b/tests/e2e/fixtures/translation-patterns/android_code/uk-rUA/android.xml new file mode 100644 index 000000000..f9de86157 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/android_code/uk-rUA/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/android_code/zh-rCN/android.xml b/tests/e2e/fixtures/translation-patterns/android_code/zh-rCN/android.xml new file mode 100644 index 000000000..f946fa36d --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/android_code/zh-rCN/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/config/crowdin.yml b/tests/e2e/fixtures/translation-patterns/config/crowdin.yml new file mode 100644 index 000000000..3bc921082 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/config/crowdin.yml @@ -0,0 +1,28 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" + +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +files: + - source: "/android_code/android.xml" + translation: "/%original_path%/%android_code%/%original_file_name%" + - source: "/doubled_asterisk/**/values/android.xml" + translation: "/doubled_asterisk/**/values-%two_letters_code%/android.xml" + - source: "/language/android.xml" + translation: "/language/%language%/%file_name%.%file_extension%" + - source: "/locale/android.xml" + translation: "/locale/%locale%/android.xml" + - source: "/locale_with_underscore/android.xml" + translation: "/locale_with_underscore/%locale_with_underscore%/android.xml" + - source: "/osx_code/android.xml" + translation: "/osx_code/%osx_code%/android.xml" + - source: "/osx_locale/android.xml" + translation: "/osx_locale/%osx_locale%/android.xml" + - source: "/three_letters_code/android.xml" + translation: "/three_letters_code/%three_letters_code%/android.xml" + - source: "/two_letters_code/android.xml" + translation: "/two_letters_code/%two_letters_code%/android.xml" + - source: "/two_letters_code_with_original_path/android.xml" + translation: "/%original_path%/%original_path%-%two_letters_code%/android.xml" diff --git a/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values-uk/android.xml b/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values-uk/android.xml new file mode 100644 index 000000000..9058e6322 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values-uk/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values-zh/android.xml b/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values-zh/android.xml new file mode 100644 index 000000000..50a91e9c7 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values-zh/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values/android.xml b/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values/android.xml new file mode 100644 index 000000000..d631aa611 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/doubled_asterisk/res/values/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/language/Chinese Simplified/android.xml b/tests/e2e/fixtures/translation-patterns/language/Chinese Simplified/android.xml new file mode 100644 index 000000000..d22ab8349 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/language/Chinese Simplified/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/language/Ukrainian/android.xml b/tests/e2e/fixtures/translation-patterns/language/Ukrainian/android.xml new file mode 100644 index 000000000..fab312c10 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/language/Ukrainian/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/language/android.xml b/tests/e2e/fixtures/translation-patterns/language/android.xml new file mode 100644 index 000000000..dcba8148d --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/language/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/locale/android.xml b/tests/e2e/fixtures/translation-patterns/locale/android.xml new file mode 100644 index 000000000..acfc06a9e --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/locale/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/locale/uk-UA/android.xml b/tests/e2e/fixtures/translation-patterns/locale/uk-UA/android.xml new file mode 100644 index 000000000..e4462723c --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/locale/uk-UA/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/locale/zh-CN/android.xml b/tests/e2e/fixtures/translation-patterns/locale/zh-CN/android.xml new file mode 100644 index 000000000..7b7708e36 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/locale/zh-CN/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/locale_with_underscore/android.xml b/tests/e2e/fixtures/translation-patterns/locale_with_underscore/android.xml new file mode 100644 index 000000000..e0997fa30 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/locale_with_underscore/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/locale_with_underscore/uk_UA/android.xml b/tests/e2e/fixtures/translation-patterns/locale_with_underscore/uk_UA/android.xml new file mode 100644 index 000000000..dd8df911c --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/locale_with_underscore/uk_UA/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/locale_with_underscore/zh_CN/android.xml b/tests/e2e/fixtures/translation-patterns/locale_with_underscore/zh_CN/android.xml new file mode 100644 index 000000000..175eb964b --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/locale_with_underscore/zh_CN/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/osx_code/android.xml b/tests/e2e/fixtures/translation-patterns/osx_code/android.xml new file mode 100644 index 000000000..111621548 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/osx_code/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/osx_code/uk.lproj/android.xml b/tests/e2e/fixtures/translation-patterns/osx_code/uk.lproj/android.xml new file mode 100644 index 000000000..73d250c52 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/osx_code/uk.lproj/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/osx_code/zh-Hans.lproj/android.xml b/tests/e2e/fixtures/translation-patterns/osx_code/zh-Hans.lproj/android.xml new file mode 100644 index 000000000..e8480082c --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/osx_code/zh-Hans.lproj/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/osx_locale/android.xml b/tests/e2e/fixtures/translation-patterns/osx_locale/android.xml new file mode 100644 index 000000000..9e1cdd52a --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/osx_locale/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/osx_locale/uk/android.xml b/tests/e2e/fixtures/translation-patterns/osx_locale/uk/android.xml new file mode 100644 index 000000000..dea112913 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/osx_locale/uk/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/osx_locale/zh-Hans/android.xml b/tests/e2e/fixtures/translation-patterns/osx_locale/zh-Hans/android.xml new file mode 100644 index 000000000..7fa7507da --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/osx_locale/zh-Hans/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/three_letters_code/android.xml b/tests/e2e/fixtures/translation-patterns/three_letters_code/android.xml new file mode 100644 index 000000000..8678a8106 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/three_letters_code/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/three_letters_code/ukr/android.xml b/tests/e2e/fixtures/translation-patterns/three_letters_code/ukr/android.xml new file mode 100644 index 000000000..65d96ae82 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/three_letters_code/ukr/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/three_letters_code/zho/android.xml b/tests/e2e/fixtures/translation-patterns/three_letters_code/zho/android.xml new file mode 100644 index 000000000..517e5f58c --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/three_letters_code/zho/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/two_letters_code/android.xml b/tests/e2e/fixtures/translation-patterns/two_letters_code/android.xml new file mode 100644 index 000000000..d631aa611 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/two_letters_code/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/two_letters_code/uk/android.xml b/tests/e2e/fixtures/translation-patterns/two_letters_code/uk/android.xml new file mode 100644 index 000000000..9058e6322 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/two_letters_code/uk/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/two_letters_code/zh/android.xml b/tests/e2e/fixtures/translation-patterns/two_letters_code/zh/android.xml new file mode 100644 index 000000000..50a91e9c7 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/two_letters_code/zh/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/android.xml b/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/android.xml new file mode 100644 index 000000000..d631aa611 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml b/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml new file mode 100644 index 000000000..9058e6322 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml b/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml new file mode 100644 index 000000000..50a91e9c7 --- /dev/null +++ b/tests/e2e/fixtures/translation-patterns/two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml @@ -0,0 +1,5 @@ + + + 第一个字符串 + 第二个字符串 + diff --git a/tests/e2e/fixtures/translation-replace/config/crowdin.yml b/tests/e2e/fixtures/translation-replace/config/crowdin.yml new file mode 100644 index 000000000..3c051e8e9 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/config/crowdin.yml @@ -0,0 +1,15 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "./files" +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + # "%original_path%" is the source file's parent directory INCLUDING the source pattern's + # fixed "en/" prefix - e.g. "en/src/main/resources" for "en/src/main/resources/android.xml" - + # so "translation_replace" is what strips that "en/" back out and lands the file on + # "it/src/main/resources/android.xml". Keeping both keys literal is the point of this suite: + # "translation_replace" has nothing to do unless "%original_path%" carries a prefix. + - source: "/en/**/*.xml" + translation: "/%two_letters_code%/%original_path%/%original_file_name%" + translation_replace: + "/en": "" diff --git a/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/android.xml b/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/org/crowdin/android.xml b/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/org/crowdin/android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/org/crowdin/android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/org/crowdin/strings.xml b/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/org/crowdin/strings.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/expected/it/src/main/resources/org/crowdin/strings.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/android.xml b/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/org/crowdin/android.xml b/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/org/crowdin/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/org/crowdin/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/org/crowdin/strings.xml b/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/org/crowdin/strings.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/expected/uk/src/main/resources/org/crowdin/strings.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/android.xml b/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/org/crowdin/android.xml b/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/org/crowdin/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/org/crowdin/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/org/crowdin/strings.xml b/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/org/crowdin/strings.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/en/src/main/resources/org/crowdin/strings.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/android.xml b/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/org/crowdin/android.xml b/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/org/crowdin/android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/org/crowdin/android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/org/crowdin/strings.xml b/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/org/crowdin/strings.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/it/src/main/resources/org/crowdin/strings.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/android.xml b/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/org/crowdin/android.xml b/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/org/crowdin/android.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/org/crowdin/android.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/org/crowdin/strings.xml b/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/org/crowdin/strings.xml new file mode 100644 index 000000000..efc1dab05 --- /dev/null +++ b/tests/e2e/fixtures/translation-replace/files/uk/src/main/resources/org/crowdin/strings.xml @@ -0,0 +1,5 @@ + + + перший рядок + другий рядок + diff --git a/tests/e2e/fixtures/translations-not-match/alt-configs/multi-file.yml b/tests/e2e/fixtures/translations-not-match/alt-configs/multi-file.yml new file mode 100644 index 000000000..d6cd2475c --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/alt-configs/multi-file.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/translations-not-match/alt-configs/no-sources.yml b/tests/e2e/fixtures/translations-not-match/alt-configs/no-sources.yml new file mode 100644 index 000000000..8d5d44868 --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/alt-configs/no-sources.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/not_in_project.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/translations-not-match/alt-configs/single-file.yml b/tests/e2e/fixtures/translations-not-match/alt-configs/single-file.yml new file mode 100644 index 000000000..fe4ef0314 --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/alt-configs/single-file.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/1_android.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/translations-not-match/config/crowdin.yml b/tests/e2e/fixtures/translations-not-match/config/crowdin.yml new file mode 100644 index 000000000..d6cd2475c --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/translations-not-match/expected/it/1_android.xml b/tests/e2e/fixtures/translations-not-match/expected/it/1_android.xml new file mode 100644 index 000000000..dc3532781 --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/expected/it/1_android.xml @@ -0,0 +1,5 @@ + + + firs string + second string + diff --git a/tests/e2e/fixtures/translations-not-match/expected/uk/1_android.xml b/tests/e2e/fixtures/translations-not-match/expected/uk/1_android.xml new file mode 100644 index 000000000..dc3532781 --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/expected/uk/1_android.xml @@ -0,0 +1,5 @@ + + + firs string + second string + diff --git a/tests/e2e/fixtures/translations-not-match/sources/1_android.xml b/tests/e2e/fixtures/translations-not-match/sources/1_android.xml new file mode 100644 index 000000000..dc3532781 --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/sources/1_android.xml @@ -0,0 +1,5 @@ + + + firs string + second string + diff --git a/tests/e2e/fixtures/translations-not-match/sources/2_android.xml b/tests/e2e/fixtures/translations-not-match/sources/2_android.xml new file mode 100644 index 000000000..3db26f896 --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/sources/2_android.xml @@ -0,0 +1,6 @@ + + + first string + second string + third string + diff --git a/tests/e2e/fixtures/translations-not-match/sources/3_android.xml b/tests/e2e/fixtures/translations-not-match/sources/3_android.xml new file mode 100644 index 000000000..3db26f896 --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/sources/3_android.xml @@ -0,0 +1,6 @@ + + + first string + second string + third string + diff --git a/tests/e2e/fixtures/translations-not-match/sources/java.properties b/tests/e2e/fixtures/translations-not-match/sources/java.properties new file mode 100644 index 000000000..bd7c9a3c4 --- /dev/null +++ b/tests/e2e/fixtures/translations-not-match/sources/java.properties @@ -0,0 +1,3 @@ +str1=first string +str2=second string +str3=third string diff --git a/tests/e2e/fixtures/update-option/config/crowdin.yml b/tests/e2e/fixtures/update-option/config/crowdin.yml new file mode 100644 index 000000000..16adff20c --- /dev/null +++ b/tests/e2e/fixtures/update-option/config/crowdin.yml @@ -0,0 +1,14 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/kept.json" + translation: "/translations/%two_letters_code%/%original_file_name%" + update_option: "update_as_unapproved" + - source: "/sources/plain.json" + translation: "/translations/%two_letters_code%/%original_file_name%" + - source: "/sources/approved.json" + translation: "/translations/%two_letters_code%/%original_file_name%" + update_option: "update_without_changes" diff --git a/tests/e2e/fixtures/update-option/sources/approved.json b/tests/e2e/fixtures/update-option/sources/approved.json new file mode 100644 index 000000000..f750bd110 --- /dev/null +++ b/tests/e2e/fixtures/update-option/sources/approved.json @@ -0,0 +1,3 @@ +{ + "greeting": "Hello" +} diff --git a/tests/e2e/fixtures/update-option/sources/kept.json b/tests/e2e/fixtures/update-option/sources/kept.json new file mode 100644 index 000000000..f750bd110 --- /dev/null +++ b/tests/e2e/fixtures/update-option/sources/kept.json @@ -0,0 +1,3 @@ +{ + "greeting": "Hello" +} diff --git a/tests/e2e/fixtures/update-option/sources/plain.json b/tests/e2e/fixtures/update-option/sources/plain.json new file mode 100644 index 000000000..f750bd110 --- /dev/null +++ b/tests/e2e/fixtures/update-option/sources/plain.json @@ -0,0 +1,3 @@ +{ + "greeting": "Hello" +} diff --git a/tests/e2e/fixtures/upload-single-file/config/crowdin.yml b/tests/e2e/fixtures/upload-single-file/config/crowdin.yml new file mode 100644 index 000000000..1a97af697 --- /dev/null +++ b/tests/e2e/fixtures/upload-single-file/config/crowdin.yml @@ -0,0 +1,12 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +# Only exercised by the one test that runs `upload sources` with no `-s`/`-t` override (the +# "empty files" scenario) -- every other test passes `-s`/`-t` on the command line, which fully +# replaces this `files:` entry via `cli/config.ts`'s `cliLayer()` (see the suite file's top comment). +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/upload-single-file/sources/1_android.xml b/tests/e2e/fixtures/upload-single-file/sources/1_android.xml new file mode 100644 index 000000000..dfb4dcb51 --- /dev/null +++ b/tests/e2e/fixtures/upload-single-file/sources/1_android.xml @@ -0,0 +1,6 @@ + + + first string source file1 + second string source file1 + third string source file1 + diff --git a/tests/e2e/fixtures/upload-single-file/sources/2_android.xml b/tests/e2e/fixtures/upload-single-file/sources/2_android.xml new file mode 100644 index 000000000..53bc1c371 --- /dev/null +++ b/tests/e2e/fixtures/upload-single-file/sources/2_android.xml @@ -0,0 +1,6 @@ + + + first string source file2 + second string source file2 + third string source file2 + diff --git a/tests/e2e/fixtures/upload-single-file/sources/empty_android.xml b/tests/e2e/fixtures/upload-single-file/sources/empty_android.xml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/fixtures/upload-single-file/sources/empty_android2.xml b/tests/e2e/fixtures/upload-single-file/sources/empty_android2.xml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/fixtures/upload-sources/alt-configs/with-context.yml b/tests/e2e/fixtures/upload-sources/alt-configs/with-context.yml new file mode 100644 index 000000000..b06cb3030 --- /dev/null +++ b/tests/e2e/fixtures/upload-sources/alt-configs/with-context.yml @@ -0,0 +1,9 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.json" + translation: "/translations/%two_letters_code%/%original_file_name%" + context: "from %original_file_name%" diff --git a/tests/e2e/fixtures/upload-sources/config/crowdin.yml b/tests/e2e/fixtures/upload-sources/config/crowdin.yml new file mode 100644 index 000000000..08b3a16fd --- /dev/null +++ b/tests/e2e/fixtures/upload-sources/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.json" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/upload-sources/sources/alpha.json b/tests/e2e/fixtures/upload-sources/sources/alpha.json new file mode 100644 index 000000000..91e052255 --- /dev/null +++ b/tests/e2e/fixtures/upload-sources/sources/alpha.json @@ -0,0 +1,4 @@ +{ + "greeting": "Hello", + "farewell": "Bye" +} diff --git a/tests/e2e/fixtures/upload-sources/sources/beta.json b/tests/e2e/fixtures/upload-sources/sources/beta.json new file mode 100644 index 000000000..0670f0013 --- /dev/null +++ b/tests/e2e/fixtures/upload-sources/sources/beta.json @@ -0,0 +1,3 @@ +{ + "title": "Title" +} diff --git a/tests/e2e/fixtures/upload-translations/config/crowdin.yml b/tests/e2e/fixtures/upload-translations/config/crowdin.yml new file mode 100644 index 000000000..08b3a16fd --- /dev/null +++ b/tests/e2e/fixtures/upload-translations/config/crowdin.yml @@ -0,0 +1,8 @@ +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/sources/*.json" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/upload-translations/sources/messages.json b/tests/e2e/fixtures/upload-translations/sources/messages.json new file mode 100644 index 000000000..4733e0b1b --- /dev/null +++ b/tests/e2e/fixtures/upload-translations/sources/messages.json @@ -0,0 +1,5 @@ +{ + "greeting": "Hello", + "shared": "Same in both", + "secret": "Secret text" +} diff --git a/tests/e2e/fixtures/upload-translations/translations/uk/messages.json b/tests/e2e/fixtures/upload-translations/translations/uk/messages.json new file mode 100644 index 000000000..610e51eb4 --- /dev/null +++ b/tests/e2e/fixtures/upload-translations/translations/uk/messages.json @@ -0,0 +1,5 @@ +{ + "greeting": "Привіт", + "shared": "Same in both", + "secret": "Таємний текст" +} diff --git a/tests/e2e/fixtures/without-config-param/config/crowdin.yml b/tests/e2e/fixtures/without-config-param/config/crowdin.yml new file mode 100644 index 000000000..0f3aeca21 --- /dev/null +++ b/tests/e2e/fixtures/without-config-param/config/crowdin.yml @@ -0,0 +1,10 @@ +# Moved between crowdin.yml and crowdin.yaml by the suite to test default config discovery. +project_id: "{{projectId}}" +api_token: "{{token}}" +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: false + +files: + - source: "/sources/*.xml" + translation: "/translations/%two_letters_code%/%original_file_name%" diff --git a/tests/e2e/fixtures/without-config-param/sources/android.xml b/tests/e2e/fixtures/without-config-param/sources/android.xml new file mode 100644 index 000000000..a1863565c --- /dev/null +++ b/tests/e2e/fixtures/without-config-param/sources/android.xml @@ -0,0 +1,5 @@ + + + first string + second string + diff --git a/tests/e2e/fixtures/without-config-param/translations/it/android.xml b/tests/e2e/fixtures/without-config-param/translations/it/android.xml new file mode 100644 index 000000000..c3be6fc68 --- /dev/null +++ b/tests/e2e/fixtures/without-config-param/translations/it/android.xml @@ -0,0 +1,5 @@ + + + prima stringa + seconda stringa + diff --git a/tests/e2e/fixtures/without-config-param/translations/uk/android.xml b/tests/e2e/fixtures/without-config-param/translations/uk/android.xml new file mode 100644 index 000000000..293507095 --- /dev/null +++ b/tests/e2e/fixtures/without-config-param/translations/uk/android.xml @@ -0,0 +1,5 @@ + + + першоа стрічка + друга стрічка + diff --git a/tests/e2e/helpers/cli.ts b/tests/e2e/helpers/cli.ts new file mode 100644 index 000000000..0c0f987fb --- /dev/null +++ b/tests/e2e/helpers/cli.ts @@ -0,0 +1,88 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export interface CliResult { + stdout: string; + stderr: string; + exitCode: number; + /** True when the call was killed by the per-run timeout rather than exiting on its own. */ + timedOut: boolean; +} + +export interface CliRunOptions { + /** Environment variables merged onto the (credential-stripped) process env. */ + env?: Record; + /** Working directory; defaults to the workspace. */ + cwd?: string; + /** Skip the auto-appended `-c ` flag (`--no-progress` is always added - see run()). */ + noConfig?: boolean; + /** Leave `--no-colors` off, for the tests that check colored output. */ + colors?: boolean; + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 120_000; +const REPO_ROOT = join(import.meta.dir, '..', '..', '..'); + +// Ambient credentials a dev machine may have and CI never does: these vars, and a `~/.crowdin.yml` +// identity file, which outranks the config file. +const CREDENTIAL_ENV_VARS = ['CROWDIN_PROJECT_ID', 'CROWDIN_PERSONAL_TOKEN', 'CROWDIN_BASE_PATH', 'CROWDIN_BASE_URL']; +// Shared rather than per workspace: bun writes its transpiler cache under HOME. +// ponytail: HOME only - Windows reads USERPROFILE, add it if e2e ever runs there. +const ISOLATED_HOME = join(tmpdir(), 'crowdin-e2e-home'); + +const CLI_COMMAND = ['bun', join(REPO_ROOT, 'src-next', 'cli.ts')]; + +export class CliRunner { + constructor(private readonly opts: { workspace: string; configPath: string }) {} + + async run(args: string[], runOpts: CliRunOptions = {}): Promise { + const fullArgs = [...args]; + + if (!runOpts.noConfig) { + fullArgs.push('-c', this.opts.configPath); + } + + // Always appended, `noConfig` or not: without `--no-progress` the spinner's frames land in + // stdout, and how many depends on how long the call took. + fullArgs.push('--no-progress'); + + if (!runOpts.colors) { + fullArgs.push('--no-colors'); + } + + // FORCE_COLOR pinned: `bun test --parallel` sets it only on a TTY. The `colors` tests need it on + // a pipe, and --no-colors must win over it everywhere else. + const env: Record = { ...process.env, HOME: ISOLATED_HOME, FORCE_COLOR: '1' }; + + for (const key of CREDENTIAL_ENV_VARS) { + delete env[key]; + } + + Object.assign(env, runOpts.env); + + const proc = Bun.spawn([...CLI_COMMAND, ...fullArgs], { + cwd: runOpts.cwd ?? this.opts.workspace, + env, + stdout: 'pipe', + stderr: 'pipe', + }); + + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + proc.kill(); + }, runOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode, timedOut }; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/e2e/helpers/config.test.ts b/tests/e2e/helpers/config.test.ts similarity index 75% rename from e2e/helpers/config.test.ts rename to tests/e2e/helpers/config.test.ts index f5c2e6a7a..b46b07a80 100644 --- a/e2e/helpers/config.test.ts +++ b/tests/e2e/helpers/config.test.ts @@ -14,6 +14,12 @@ describe('renderConfig', () => { expect(renderConfig('{{token}} {{token}}', { projectId: 1, token: 't' })).toBe('t t'); }); + test('JSON-encodes non-string values, so an array renders as a flow sequence', () => { + expect(renderConfig('ignore: {{ignore}}', { projectId: 1, token: 't', ignore: ['/a/*.xml', '/b'] })).toBe( + 'ignore: ["/a/*.xml","/b"]', + ); + }); + test('throws on an unknown placeholder (typo, or a static value left as a template)', () => { expect(() => renderConfig('base_path: "{{basePath}}"', { projectId: 1, token: 't' })).toThrow(/basePath/); }); diff --git a/e2e/helpers/config.ts b/tests/e2e/helpers/config.ts similarity index 57% rename from e2e/helpers/config.ts rename to tests/e2e/helpers/config.ts index 5cd7f3a4d..2c5206e66 100644 --- a/e2e/helpers/config.ts +++ b/tests/e2e/helpers/config.ts @@ -3,26 +3,22 @@ import { join } from 'node:path'; export interface ConfigValues { projectId: number | string; token: string; + [placeholder: string]: unknown; } /** - * Render a `crowdin.yml` template by replacing `{{projectId}}` and `{{token}}`. + * Render a `crowdin.yml` template by replacing each `{{name}}` with `values[name]`. Strings go in + * as-is; anything else is JSON-encoded, so an array lands as a YAML flow sequence. */ export function renderConfig(template: string, values: ConfigValues): string { - const resolved: Record = { - projectId: String(values.projectId), - token: values.token, - }; - - const rendered = template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => { - if (!(key in resolved)) { + return template.replace(/\{\{(\w+)}}/g, (_match, key: string) => { + if (!(key in values)) { throw new Error(`No value provided for placeholder {{${key}}} in crowdin.yml template`); } - return resolved[key] as string; + const value = values[key]; + return typeof value === 'string' ? value : JSON.stringify(value); }); - - return rendered; } export async function writeConfig(workspace: string, template: string, values: ConfigValues): Promise { diff --git a/e2e/helpers/env.ts b/tests/e2e/helpers/env.ts similarity index 100% rename from e2e/helpers/env.ts rename to tests/e2e/helpers/env.ts diff --git a/tests/e2e/helpers/files.ts b/tests/e2e/helpers/files.ts new file mode 100644 index 000000000..9c0121ed6 --- /dev/null +++ b/tests/e2e/helpers/files.ts @@ -0,0 +1,90 @@ +import { expect } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** Every file under `root`, as sorted `/`-separated paths relative to it. */ +export async function listFilesRecursively(root: string): Promise { + return (await Array.fromAsync(new Bun.Glob('**').scan({ cwd: root, dot: true }))).sort(); +} + +/** + * Assert that every given path (relative to `workspace`) exists. Reports all + * missing paths at once instead of failing on the first. + */ +export async function expectFilesExist(workspace: string, ...relativePaths: string[]): Promise { + const missing: string[] = []; + + for (const relativePath of relativePaths) { + if (!(await Bun.file(join(workspace, relativePath)).exists())) { + missing.push(relativePath); + } + } + + expect(missing).toEqual([]); +} + +/** + * Assert that each path exists under `actualDir` with the same content as under `expectedDir` (all + * relative to `workspace`). Compared as one path-to-content map, so a failure names every file that + * differs. + */ +export async function expectFilesMatch( + workspace: string, + actualDir: string, + expectedDir: string, + ...relativePaths: string[] +): Promise { + await expectFilesExist(workspace, ...relativePaths.map((path) => join(actualDir, path))); + + const read = async (dir: string) => + Object.fromEntries( + await Promise.all( + relativePaths.map(async (path) => [path, await Bun.file(join(workspace, dir, path)).text()] as const), + ), + ); + + expect(await read(actualDir)).toEqual(await read(expectedDir)); +} + +/** + * Record the content of every given path and delete it. Most suites' `translation` patterns resolve + * to paths their upload fixtures already occupy, so a download that silently writes nothing would + * still leave those files on disk and pass an existence check - and comparing a file against content + * read from that same file would trivially pass too. Clearing first makes the download prove itself. + * + * Pair with `expectRestored`. + */ +export async function captureAndClear(workspace: string, ...relativePaths: string[]): Promise> { + const captured = new Map(); + + for (const relativePath of relativePaths) { + captured.set(relativePath, await Bun.file(join(workspace, relativePath)).text()); + await rm(join(workspace, relativePath), { force: true }); + } + + return captured; +} + +/** Assert a download recreated every path `captureAndClear` cleared, with the content it had. */ +export async function expectRestored(workspace: string, captured: Map): Promise { + await expectFilesExist(workspace, ...captured.keys()); + + for (const [relativePath, content] of captured) { + expect(await Bun.file(join(workspace, relativePath)).text()).toBe(content); + } +} + +/** + * Read back content a suite captured earlier (keyed by relative path), for comparing a re-downloaded + * file against what was uploaded. Throws when the key was never recorded, so a typo'd key or a + * capture step that silently didn't run fails the test instead of comparing against `undefined`. + */ +export function capturedContent(captured: Map, key: string): string { + const content = captured.get(key); + + if (content === undefined) { + throw new Error(`No content was captured for '${key}'`); + } + + return content; +} diff --git a/tests/e2e/helpers/lookup.ts b/tests/e2e/helpers/lookup.ts new file mode 100644 index 000000000..a6791ff6e --- /dev/null +++ b/tests/e2e/helpers/lookup.ts @@ -0,0 +1,60 @@ +import type { SuiteContext } from './suite.ts'; + +/** The first entry whose `data` satisfies `predicate`, or a thrown error naming what was missing. */ +function requireMatch(entries: { data: T }[], predicate: (data: T) => boolean, what: string): T { + const match = entries.find((entry) => predicate(entry.data)); + + if (!match) { + throw new Error(`${what} not found via the API`); + } + + return match.data; +} + +/** Every source file path in the project, sorted. */ +export async function projectFilePaths(ctx: SuiteContext): Promise { + const files = await ctx.client.sourceFilesApi.listProjectFiles(ctx.project.id, { recursion: '1' }); + return files.data.map((file) => file.data.path).sort(); +} + +/** The id of the project string whose text is exactly `text`, optionally narrowed to a branch or file. */ +export async function findStringId( + ctx: SuiteContext, + text: string, + scope: { branchId?: number; fileId?: number } = {}, +): Promise { + const response = await ctx.client.sourceStringsApi + .withFetchAll() + .listProjectStrings(ctx.project.id, { ...scope, filter: text }); + return requireMatch(response.data, (string) => string.text === text, `String '${text}'`).id; +} + +export async function findBranch(ctx: SuiteContext, name: string, projectId = ctx.project.id) { + const response = await ctx.client.sourceFilesApi.withFetchAll().listProjectBranches(projectId, { name }); + return requireMatch(response.data, (branch) => branch.name === name, `Branch '${name}'`); +} + +export async function findFileId(ctx: SuiteContext, projectPath: string): Promise { + const response = await ctx.client.sourceFilesApi.withFetchAll().listProjectFiles(ctx.project.id); + return requireMatch(response.data, (file) => file.path === projectPath, `File '${projectPath}'`).id; +} + +export async function findCommentId(ctx: SuiteContext, text: string): Promise { + const response = await ctx.client.stringCommentsApi.withFetchAll().listStringComments(ctx.project.id); + return requireMatch(response.data, (comment) => comment.text === text, `Comment '${text}'`).id; +} + +export async function findGlossaryId(ctx: SuiteContext, name: string): Promise { + const response = await ctx.client.glossariesApi.withFetchAll().listGlossaries(); + return requireMatch(response.data, (glossary) => glossary.name === name, `Glossary '${name}'`).id; +} + +export async function findTmId(ctx: SuiteContext, name: string): Promise { + const response = await ctx.client.translationMemoryApi.withFetchAll().listTm(); + return requireMatch(response.data, (tm) => tm.name === name, `Translation memory '${name}'`).id; +} + +export async function translationCount(ctx: SuiteContext, stringId: number, languageId: string): Promise { + const response = await ctx.client.stringTranslationsApi.listStringTranslations(ctx.project.id, stringId, languageId); + return response.data.length; +} diff --git a/tests/e2e/helpers/normalize.test.ts b/tests/e2e/helpers/normalize.test.ts new file mode 100644 index 000000000..729f07b10 --- /dev/null +++ b/tests/e2e/helpers/normalize.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from 'bun:test'; +import { normalize } from './normalize.ts'; + +describe('normalize', () => { + test('strips ANSI color/control sequences', () => { + expect(normalize('\x1b[32mhello\x1b[0m')).toBe('hello'); + }); + + test('removes zero-width invisible characters', () => { + expect(normalize('a\u200Bb\uFEFF')).toBe('ab'); + }); + + test('masks #-prefixed ids but leaves bare counts alone', () => { + expect(normalize('Created string #12345')).toBe('Created string #id'); + expect(normalize('Uploaded 3 files')).toBe('Uploaded 3 files'); + }); + + test('masks durations', () => { + expect(normalize('done in 1.23s')).toBe('done in '); + expect(normalize('took 450ms')).toBe('took '); + }); + + test('sorts siblings within a marker block so emission order is irrelevant', () => { + const a = normalize('◆ file b\n◆ file a\n◆ file c'); + const b = normalize('◆ file c\n◆ file b\n◆ file a'); + expect(a).toBe(b); + expect(a).toBe('◆ file a\n◆ file b\n◆ file c'); + }); + + test('sorts within each marker block but keeps the blocks in emission order', () => { + const raw = ['● Project info fetched', '● Fetching project info', '◆ File b created', '◆ File a created'].join( + '\n', + ); + expect(normalize(raw)).toBe( + ['● Fetching project info', '● Project info fetched', '◆ File a created', '◆ File b created'].join('\n'), + ); + }); + + test('does not reorder across blocks even when a later block sorts first globally', () => { + // A naive global sort would float the ◆ lines above the ● lines (◆ < ●); + // grouping keeps the ● block first because it was emitted first. + expect(normalize('● b\n● a\n◆ b\n◆ a')).toBe('● a\n● b\n◆ a\n◆ b'); + }); + + test('collapses the volatile temp workspace root to a stable token', () => { + const a = normalize("skeleton '/private/var/folders/v9/x32/T/crowdin-e2e/70577-316735/init/crowdin.yaml'"); + const b = normalize("skeleton '/private/var/folders/3q/0y5/T/crowdin-e2e/27926-819747/init/crowdin.yaml'"); + expect(a).toBe(b); + expect(a).toBe("skeleton '/init/crowdin.yaml'"); + }); + + test('leaves absolute paths without the temp marker untouched', () => { + // The mask is anchored on `crowdin-e2e/-`; a real path (e.g. a base_path the CLI + // echoes) must survive, otherwise the mask would swallow load-bearing paths in assertions. + const line = "base_path: '/Users/dev/project/crowdin.yaml'"; + expect(normalize(line)).toBe(line); + }); + + test('masks multiple temp paths on one line independently', () => { + // The `[^\s'"]*` prefix (not `.*`) stops at the space between the two paths, so the global + // replace masks each root separately instead of spanning the gap and merging them. + const line = 'copied /tmp/crowdin-e2e/1-2/a/x.yaml to /tmp/crowdin-e2e/3-4/b/y.yaml'; + expect(normalize(line)).toBe('copied /a/x.yaml to /b/y.yaml'); + }); + + test('drops blank lines and trailing whitespace', () => { + expect(normalize('a \n\nb')).toBe('a\nb'); + }); + + test('normalizes interleaved status lines to the same result regardless of race order', () => { + // The real flake: a `◆` success line for one file lands between two `●` lines, and which one + // wins is a race. Both orderings must normalize identically or the snapshot is a coin flip. + const raceA = ['● Importing a', '● Importing b', '◆ File a', '◆ File b'].join('\n'); + const raceB = ['● Importing a', '◆ File a', '● Importing b', '◆ File b'].join('\n'); + + expect(normalize(raceA)).toBe(normalize(raceB)); + expect(normalize(raceB)).toBe(['● Importing a', '● Importing b', '◆ File a', '◆ File b'].join('\n')); + }); + + test('keeps table rows within their own table instead of merging every table', () => { + // Table rows share the `│` marker but are not concurrent output, so they keep the + // contiguous-run rule - two separate tables must not be sorted into one another. + const output = ['│ b │', '│ a │', 'between', '│ d │', '│ c │'].join('\n'); + + expect(normalize(output)).toBe(['│ a │', '│ b │', 'between', '│ c │', '│ d │'].join('\n')); + }); + + test('keeps error lines intact, after the gathered status block', () => { + // `■`/`✖` are sequential, not concurrent, so they are excluded from the status family and never + // sorted. An error originally interleaved with status output does land after the whole block: + // which success line it fell between was a race, so that position was never assertable. + const output = ['● Fetching', '■ something failed', '◆ File a'].join('\n'); + + expect(normalize(output)).toBe(['● Fetching', '◆ File a', '■ something failed'].join('\n')); + }); +}); + +describe('normalize: run-to-run instability', () => { + test('masks the per-run project name', () => { + expect(normalize("◆ 'e2e-1788179144-glossary's Glossary.tbx' downloaded successfully")).toBe( + "◆ 'e2e--glossary's Glossary.tbx' downloaded successfully", + ); + }); + + test('collapses repeated poll-progress lines, however many polls happened', () => { + const three = + '● Importing glossary\n● Importing glossary (100%)\n● Importing glossary (100%)\n● Importing glossary (100%)'; + const four = `${three}\n● Importing glossary (100%)`; + + expect(normalize(three)).toBe(normalize(four)); + }); + + test('keeps progress lines for distinct operations apart', () => { + // The percentage itself is masked away as noise, so what must survive is the distinction + // between two different operations reporting progress - not how far along each one got. + const output = '● Building translations (10%)\n● Building translations (100%)\n● Importing glossary (100%)'; + const result = normalize(output).split('\n'); + + expect(result).toHaveLength(2); + expect(result.some((line) => line.includes('Building translations'))).toBe(true); + expect(result.some((line) => line.includes('Importing glossary'))).toBe(true); + }); + + test('sorts report blocks as units, keeping children with their parent', () => { + const orderA = + '\t- sources/3_android.xml (2)\n\t\t- translations/it/3_android.xml\n\t- sources/2_android.xml (2)\n\t\t- translations/it/2_android.xml'; + const orderB = + '\t- sources/2_android.xml (2)\n\t\t- translations/it/2_android.xml\n\t- sources/3_android.xml (2)\n\t\t- translations/it/3_android.xml'; + + expect(normalize(orderA)).toBe(normalize(orderB)); + expect(normalize(orderA)).toBe(orderB); + }); +}); + +describe('normalize: progress percentages', () => { + test('collapses polls regardless of which percentages the run happened to catch', () => { + const caughtZero = '● Importing glossary\n● Importing glossary (0%)\n● Importing glossary (100%)'; + const missedZero = '● Importing glossary\n● Importing glossary (100%)'; + const caughtMany = + '● Importing glossary\n● Importing glossary (0%)\n● Importing glossary (37%)\n● Importing glossary (100%)\n● Importing glossary (100%)'; + + expect(normalize(caughtZero)).toBe(normalize(missedZero)); + expect(normalize(caughtMany)).toBe(normalize(missedZero)); + }); +}); + +describe('normalize: plain listings', () => { + test('sorts a bare-path listing, whose emission order is completion order', () => { + const orderA = + 'translations/it/{{cookiecutter.module_name}}/android.xml\ntranslations/it/folder/android.xml\ntranslations/uk/folder/android.xml'; + const orderB = + 'translations/it/folder/android.xml\ntranslations/uk/folder/android.xml\ntranslations/it/{{cookiecutter.module_name}}/android.xml'; + + expect(normalize(orderA)).toBe(normalize(orderB)); + }); + + test('still separates status markers from content lines', () => { + const output = '● Fetching project info\nsome/path.xml\n◆ Done'; + const result = normalize(output); + + expect(result).toContain('● Fetching project info'); + expect(result).toContain('some/path.xml'); + expect(result).toContain('◆ Done'); + }); +}); + +describe('normalize: update-check banner', () => { + // `cli/utils/checkVersion.ts` prints this after every successful command once its cache is warm. + const banner = [ + '╭───────────────────────────────────────────────────────────────────╮', + '│ Changelog: https://github.com/crowdin/crowdin-cli/releases/latest │', + '│ New version of Crowdin CLI is available! 5.0.0 -> 5.0.1 │', + '│ Please update for the best experience! │', + '╰───────────────────────────────────────────────────────────────────╯', + ].join('\n'); + + test('drops the banner wherever it lands', () => { + expect(normalize(`◆ Application has been installed\n${banner}`)).toBe('◆ Application has been installed'); + expect(normalize(`${banner}\n◆ Done`)).toBe('◆ Done'); + }); + + test('normalizes to the same output whichever release it announces', () => { + const withNext = banner.replace('5.0.0 -> 5.0.1', '5.0.0 -> 9.9.9'); + + expect(normalize(`◆ Done\n${withNext}`)).toBe(normalize(`◆ Done\n${banner}`)); + }); + + test('leaves table rows alone, which are drawn with square corners', () => { + const table = ['┌───────┐', '│ id │', '└───────┘'].join('\n'); + + expect(normalize(table)).toBe(table); + }); + + test('leaves an unrelated rounded box alone', () => { + const box = ['╭─────────────╮', '│ some notice │', '╰─────────────╯'].join('\n'); + + expect(normalize(box)).toBe(box); + }); +}); diff --git a/tests/e2e/helpers/normalize.ts b/tests/e2e/helpers/normalize.ts new file mode 100644 index 000000000..fb5fa9073 --- /dev/null +++ b/tests/e2e/helpers/normalize.ts @@ -0,0 +1,223 @@ +/** + * Black-box output normalization. Every suite calls `normalize(output)` before + * snapshotting - no per-suite configuration. The CLI emits colors, generated + * ids, timings, and parallel per-file lines in nondeterministic order, none of + * which are snapshot-stable, so `normalize`: + * + * 1. strips ANSI/invisible characters, + * 2. masks generated ids (`#123` → `#id`) and durations (`1.2s` → ``), + * 3. gathers the status lines (`●`/`▲`/`◆`) into one block, grouped by marker + * and sorted within each group, + * 4. sorts remaining lines within each contiguous run of same-marker lines. + * + * Concurrency means status lines interleave differently on every run: a `◆ File + * … created` for one file can land between two `● Importing …` lines, and which + * one wins is a race. Sorting only *contiguous* runs (the original rule) made + * that interleaving decide where the run boundaries fell, so the same output + * normalized two different ways - the single largest source of flaky snapshots + * in these suites. Collecting every status line regardless of position removes + * the race from the result: the `●` block still precedes the `◆` block (marker + * order follows first appearance), and both are internally sorted. + * + * Non-status lines keep the contiguous-run rule, which matters for table output + * (`│ … │`): rows are sorted within their own table instead of being merged + * across every table in the output. + * + * Because only siblings are sorted, snapshots don't guard the *ordering within* + * a block - suites assert load-bearing facts (counts, messages, exit codes) + * explicitly instead. + */ + +// CSI/SGR escape sequences plus standalone ESC-prefixed control sequences. +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal control codes is the point. +const ANSI = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; +// Zero-width space/non-joiner/joiner (U+200B–U+200D) and BOM (U+FEFF). +const INVISIBLE = /[\u200B-\u200D\uFEFF]/g; +// `#123`-style identifiers (string/file ids) without touching bare counts. +const IDS = /#\d+/g; +// Timing/speed values like `1.23s` or `450ms`. +const DURATIONS = /\d+(?:\.\d+)?\s?m?s\b/g; +// Per-run temp workspace root: `/crowdin-e2e/-` (see workspace.ts). +// The prefix and `-` change every run, so any absolute path the CLI echoes +// back is non-deterministic; collapse the volatile root to a stable `` token, +// leaving the suite-relative tail (e.g. `/init/crowdin.yaml`) intact. +const WORKSPACE = /[^\s'"]*\/crowdin-e2e\/\d+-\d+/g; +// Per-run project name: `e2e--` (see helpers/project.ts). The CLI echoes it +// wherever it names a project-derived entity - a project's own TM or glossary, for instance +// (`e2e-1788179144-glossary's Glossary.tbx`), which changes on every run. +const PROJECT_NAME = /e2e-\d{6,}-/g; +// Poll-driven progress lines (`Importing glossary (0%)` … `(100%)`) repeat once per poll, and BOTH +// how many appear and which percentages they caught depend on server timing - one run sees `(0%)` +// then `(100%)`, the next only `(100%)`. Masking the number first makes every poll line identical, +// so the collapse below reduces any number of them to one. Only *consecutive identical* lines +// collapse, so a genuinely repeated line for two different files still shows twice. +const PROGRESS_PERCENT = /\(\d{1,3}%\)/g; +const PROGRESS_LINE = /\(\)$/; + +/** Group key shared by every line that starts with content rather than a status marker. */ +const PLAIN = ''; + +/** + * Group key for a line: its leading whitespace-delimited token, which is the + * status marker (`◆`, `●`, …) for CLI status lines. Lines that share a marker + * form one sortable block; an unmarked line groups with adjacent lines sharing + * its first token, otherwise stands alone in emission order. + */ +function groupKey(line: string): string { + if (/^\s/.test(line)) { + // Leading whitespace (the `\t- source (n)` report lines): keyed by the whole line so each + // stands alone here and sortReportBlocks can group them into parent/child blocks instead. + return line; + } + + // The token need not be followed by whitespace: a `--output plain` line is often a single bare + // path with nothing after it, which is exactly the case that has to be grouped and sorted. + const leading = line.match(/^(\S+)/)?.[1] ?? line; + + // A leading token carrying letters or digits is content, not a status marker - a bare path from + // `--output plain`, or a sentence like 'Visit the … for more details'. A plain listing is emitted + // in completion order, so giving each such line its own group left that order in the snapshot and + // made it flip between runs; they share one sortable group instead. + return /[\p{L}\p{N}]/u.test(leading) ? PLAIN : leading; +} + +/** + * Markers the CLI emits from concurrent per-file work, so their relative order is a race. Error and + * spinner markers (`■`, `✖`, `◒`) are excluded because they are sequential - they are never sorted, + * and their text stays intact. Note they do end up *after* the gathered status block when they were + * originally interleaved with it, so a snapshot shows which errors occurred, not which success line + * they fell between - that position was never stable enough to assert on anyway. + */ +const STATUS_MARKERS = new Set(['●', '▲', '◆']); + +/** + * Text the update-check banner always carries (`cli/utils/checkVersion.ts`'s `buildBanner`). The + * banner is printed after every successful command once `~/.crowdin/version-check.json` is warm, + * and both the version pair it names and the box width derived from it change with every published + * release - so left in, it would rewrite unrelated snapshots on someone else's release schedule. + * Matched on the prose rather than the border so nothing else drawn in a rounded box is eaten. + */ +const UPDATE_BANNER_TEXT = 'New version of Crowdin CLI is available!'; + +/** + * Drop the update-check banner: the rounded box (`╭…╮` … `╰…╯`) whose body announces a new release. + * `Bun.inspect.table` draws with square corners (`┌│└`), so the rounded ones are this banner's alone, + * and the content guard keeps an unterminated box from swallowing the rest of the output. + */ +function dropUpdateBanner(lines: string[]): string[] { + const start = lines.findIndex((line) => line.startsWith('╭')); + + if (start === -1) { + return lines; + } + + const end = lines.findIndex((line, index) => index > start && line.startsWith('╰')); + + if (end === -1 || !lines.slice(start, end).some((line) => line.includes(UPDATE_BANNER_TEXT))) { + return lines; + } + + return dropUpdateBanner([...lines.slice(0, start), ...lines.slice(end + 1)]); +} + +/** Collapse consecutive identical poll-progress lines to one. */ +function collapseProgress(lines: string[]): string[] { + return lines.filter((line, index) => !(PROGRESS_LINE.test(line) && index > 0 && lines[index - 1] === line)); +} + +/** + * Report blocks (`\t- (n)` followed by `\t\t- ` children, emitted by the + * omitted-translations report) come out in Map-insertion order, which concurrency makes + * nondeterministic. Sorting line by line would tear children away from their parent, so whole + * blocks are sorted as units, children sorted inside each. + */ +function sortReportBlocks(lines: string[]): string[] { + const result: string[] = []; + + for (let i = 0; i < lines.length; ) { + if (!(lines[i] as string).startsWith('\t') || (lines[i] as string).startsWith('\t\t')) { + result.push(lines[i] as string); + i++; + continue; + } + + const blocks: { head: string; children: string[] }[] = []; + + while (i < lines.length && (lines[i] as string).startsWith('\t')) { + const line = lines[i] as string; + + if (line.startsWith('\t\t') && blocks.length > 0) { + (blocks[blocks.length - 1] as { children: string[] }).children.push(line); + } else { + blocks.push({ head: line, children: [] }); + } + + i++; + } + + blocks.sort((left, right) => (left.head < right.head ? -1 : left.head > right.head ? 1 : 0)); + result.push(...blocks.flatMap((block) => [block.head, ...block.children.sort()])); + } + + return result; +} + +/** Sort each contiguous run of same-marker lines, leaving run order untouched. */ +function sortWithinRuns(lines: string[]): string[] { + const result: string[] = []; + + for (let start = 0; start < lines.length; ) { + const key = groupKey(lines[start] as string); + let end = start + 1; + + while (end < lines.length && groupKey(lines[end] as string) === key) { + end++; + } + + result.push(...lines.slice(start, end).sort()); + start = end; + } + + return result; +} + +export function normalize(output: string): string { + const lines = output + .replace(ANSI, '') + .replace(INVISIBLE, '') + .replace(IDS, '#id') + .replace(DURATIONS, '') + .replace(WORKSPACE, '') + .replace(PROJECT_NAME, 'e2e--') + .replace(PROGRESS_PERCENT, '()') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); + + const collapsed = collapseProgress(dropUpdateBanner(lines)); + + const firstStatus = collapsed.findIndex((line) => STATUS_MARKERS.has(groupKey(line))); + + if (firstStatus === -1) { + return sortReportBlocks(sortWithinRuns(collapsed)).join('\n'); + } + + // One block holding every status line, markers in order of first appearance, sorted within each. + const byMarker = new Map(); + + for (const line of collapsed) { + const key = groupKey(line); + + if (STATUS_MARKERS.has(key)) { + byMarker.set(key, [...(byMarker.get(key) ?? []), line]); + } + } + + const statusBlock = [...byMarker.values()].flatMap((group) => group.sort()); + const before = collapsed.slice(0, firstStatus).filter((line) => !STATUS_MARKERS.has(groupKey(line))); + const after = collapsed.slice(firstStatus).filter((line) => !STATUS_MARKERS.has(groupKey(line))); + + return [...sortReportBlocks(sortWithinRuns(before)), ...statusBlock, ...sortReportBlocks(sortWithinRuns(after))].join( + '\n', + ); +} diff --git a/e2e/helpers/project.ts b/tests/e2e/helpers/project.ts similarity index 61% rename from e2e/helpers/project.ts rename to tests/e2e/helpers/project.ts index 1b906aff4..4ef73017e 100644 --- a/e2e/helpers/project.ts +++ b/tests/e2e/helpers/project.ts @@ -6,6 +6,19 @@ export interface TestProject { name: string; } +/** + * Stand-in project for suites set up with `withoutProject`. The config schema requires a positive + * `project_id` (`lib/config.ts`), so a suite whose commands never address a project still needs a + * value in `crowdin.yml` - this is one that is obviously not a real project, and nothing sends it + * anywhere. `teardownSuite` recognises it and has nothing to delete. + */ +export const SYNTHETIC_PROJECT_ID = 999999999; + +export const SYNTHETIC_PROJECT: TestProject = { + id: SYNTHETIC_PROJECT_ID, + name: 'no project created for this suite', +}; + function buildProjectName(suite: string, seconds: number): string { return `e2e-${seconds}-${suite}`; } @@ -14,6 +27,7 @@ export function createApiClient(env: E2eEnv): Client { if (!env.token) { throw new Error('Cannot create an API client without CROWDIN_E2E_TOKEN'); } + return new Client({ token: env.token }); } @@ -21,6 +35,8 @@ export interface CreateProjectOptions { suite: string; sourceLanguageId?: string; targetLanguageIds?: string[]; + /** Create a strings-based project instead of the file-based default. */ + stringsBased?: boolean; } export async function createTestProject(client: Client, opts: CreateProjectOptions): Promise { @@ -30,6 +46,9 @@ export async function createTestProject(client: Client, opts: CreateProjectOptio identifier: name, sourceLanguageId: opts.sourceLanguageId ?? 'en', targetLanguageIds: opts.targetLanguageIds ?? ['it', 'uk'], + // The API takes the project type as a BooleanInt, 1 being strings-based (same as + // `project add --string-based`). + ...(opts.stringsBased ? { type: 1 as const } : {}), }; const response = await client.projectsGroupsApi.addProject(request); diff --git a/tests/e2e/helpers/suite.ts b/tests/e2e/helpers/suite.ts new file mode 100644 index 000000000..9f871fb30 --- /dev/null +++ b/tests/e2e/helpers/suite.ts @@ -0,0 +1,174 @@ +import { expect } from 'bun:test'; +import { join } from 'node:path'; +import type { Client } from '@crowdin/crowdin-api-client'; +import { CliRunner, type CliRunOptions } from './cli.ts'; +import { renderConfig, writeConfig } from './config.ts'; +import type { E2eEnv } from './env.ts'; +import { resolveEnv } from './env.ts'; +import { + type CreateProjectOptions, + createApiClient, + createTestProject, + deleteTestProject, + SYNTHETIC_PROJECT, + SYNTHETIC_PROJECT_ID, + type TestProject, +} from './project.ts'; +import { copyFixtures, createWorkspace, removeWorkspace } from './workspace.ts'; + +/** Fixtures live at `tests/e2e/fixtures/`, resolved relative to this helper. */ +const FIXTURES_ROOT = join(import.meta.dir, '..', 'fixtures'); + +export interface SuiteContext { + env: E2eEnv; + client: Client; + workspace: string; + project: TestProject; + runner: CliRunner; + /** Projects created with {@link createExtraProject}, deleted by `teardownSuite` along with `project`. */ + extraProjects: TestProject[]; +} + +export interface SetupSuiteOptions { + sourceLanguageId?: string; + targetLanguageIds?: string[]; + /** Create a strings-based project - `branch clone`/`merge` refuse to run against any other type. */ + stringsBased?: boolean; + /** + * Skip creating a real Crowdin project. Only for suites whose commands never address one - `app` + * is the case this exists for: it sits in the project option tier, so `project_id` has to be + * *present* in the config, but none of `app list`/`install`/`uninstall` sends it to the API + * (`cli/services/AppService.ts`). The config gets {@link SYNTHETIC_PROJECT_ID} and teardown has + * nothing to delete, so the suite costs the account no project churn. + */ + withoutProject?: boolean; +} + +/** + * Compose the per-suite lifecycle: temp workspace, fixtures copied from + * `tests/e2e/fixtures/`, a fresh Crowdin project, and a rendered `crowdin.yml` + * wired into a `CliRunner`. Call from `beforeAll` with the suite name. + */ +export async function setupSuite(suite: string, opts: SetupSuiteOptions = {}): Promise { + const env = resolveEnv(); + const token = env.token; + + if (!token) { + throw new Error('CROWDIN_E2E_TOKEN is not set. E2E suites require a dedicated test-account token.'); + } + + const client = createApiClient(env); + const fixturesDir = join(FIXTURES_ROOT, suite); + const workspace = await createWorkspace(suite); + await copyFixtures(fixturesDir, workspace); + + const project = opts.withoutProject + ? SYNTHETIC_PROJECT + : await createTestProject(client, { + suite, + sourceLanguageId: opts.sourceLanguageId, + targetLanguageIds: opts.targetLanguageIds, + ...(opts.stringsBased !== undefined ? { stringsBased: opts.stringsBased } : {}), + }); + + // Everything past project creation can fail; if it does, tear down what we + // already provisioned so a partial setup doesn't orphan the project (or + // workspace) on the real account. + try { + const template = await Bun.file(join(fixturesDir, 'config', 'crowdin.yml')).text(); + const configPath = await writeConfig(workspace, template, { projectId: project.id, token }); + + const runner = new CliRunner({ workspace, configPath }); + return { env, client, workspace, project, runner, extraProjects: [] }; + } catch (error) { + await teardownSuite({ env, client, workspace, project }); + throw error; + } +} + +/** + * Render the workspace file `from` with the suite's project id and token, plus any extra `vars`, + * and write it to `to` (in place by default). Returns the written path. + */ +export async function renderFixture( + ctx: SuiteContext, + from: string, + to = from, + vars: Record = {}, +): Promise { + const template = await Bun.file(join(ctx.workspace, from)).text(); + const path = join(ctx.workspace, to); + await Bun.write(path, renderConfig(template, { ...vars, projectId: ctx.project.id, token: ctx.env.token as string })); + return path; +} + +/** + * Swap the suite's `crowdin.yml` for `/alt-configs/.yml`, rendered with the same + * project id and token plus any extra `vars`. The runner keeps pointing at the same config path, so + * every later `ctx.runner.run(...)` picks the new config up. + */ +export async function switchConfig(ctx: SuiteContext, name: string, vars: Record = {}): Promise { + await renderFixture(ctx, `alt-configs/${name}.yml`, 'crowdin.yml', vars); +} + +/** Run the CLI with `--output json`, assert it exits 0, and return the parsed stdout. */ +export async function runJson( + ctx: SuiteContext, + args: string[], + runOpts?: CliRunOptions, +): Promise> { + const result = await ctx.runner.run([...args, '--output', 'json'], runOpts); + + expect(result).toMatchObject({ exitCode: 0 }); + + return JSON.parse(result.stdout) as T; +} + +/** + * Create a second project for a suite that needs one of another kind (e.g. strings-based), and + * register it so `teardownSuite` deletes it too. Returns its id. + */ +export async function createExtraProject(ctx: SuiteContext, opts: CreateProjectOptions): Promise { + const project = await createTestProject(ctx.client, opts); + ctx.extraProjects.push(project); + return project.id; +} + +/** + * Tear down a suite: delete the project and remove the workspace (which holds + * everything the suite produced, including downloaded files). Honors + * `CROWDIN_E2E_KEEP=1`. Cleanup failures are logged, never thrown, so one failed + * deletion can't mask a real test result. Call from `afterAll`. + */ +export async function teardownSuite( + ctx: + | (Pick & Partial>) + | undefined, +): Promise { + if (!ctx) { + return; + } + + if (ctx.env.keep) { + console.log(`CROWDIN_E2E_KEEP=1 - keeping project #${ctx.project.id} (${ctx.project.name}) and ${ctx.workspace}`); + return; + } + + // A `withoutProject` suite never created one, so there is nothing to delete - and the synthetic + // id must never be sent to deleteProject, which would address someone else's project. + const projects = [ctx.project, ...(ctx.extraProjects ?? [])].filter(({ id }) => id !== SYNTHETIC_PROJECT_ID); + + for (const project of projects) { + try { + await deleteTestProject(ctx.client, project.id); + } catch (error) { + console.error(`Failed to delete project #${project.id}: ${error instanceof Error ? error.message : error}`); + } + } + + try { + await removeWorkspace(ctx.workspace); + } catch (error) { + console.error(`Failed to remove workspace ${ctx.workspace}: ${error instanceof Error ? error.message : error}`); + } +} diff --git a/e2e/helpers/workspace.test.ts b/tests/e2e/helpers/workspace.test.ts similarity index 91% rename from e2e/helpers/workspace.test.ts rename to tests/e2e/helpers/workspace.test.ts index 76a007ece..a075c3f45 100644 --- a/e2e/helpers/workspace.test.ts +++ b/tests/e2e/helpers/workspace.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { copyFixtures, createWorkspace, removeWorkspace } from './workspace.ts'; @@ -12,8 +12,9 @@ afterEach(async () => { } }); +// realpath to match createWorkspace, which resolves symlinks (on macOS /var -> /private/var). async function scratch(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'ws-test-')); + const dir = await realpath(await mkdtemp(join(tmpdir(), 'ws-test-'))); tmpRoots.push(dir); return dir; } diff --git a/e2e/helpers/workspace.ts b/tests/e2e/helpers/workspace.ts similarity index 70% rename from e2e/helpers/workspace.ts rename to tests/e2e/helpers/workspace.ts index 02a836970..efcd0c0f3 100644 --- a/e2e/helpers/workspace.ts +++ b/tests/e2e/helpers/workspace.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, rm } from 'node:fs/promises'; +import { cp, mkdir, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -13,11 +13,19 @@ export interface WorkspaceOptions { root?: string; } -/** Create (and return) a fresh per-suite workspace directory. */ +/** + * Create (and return) a fresh per-suite workspace directory. + * + * Resolved via `realpath`: on macOS, `os.tmpdir()` lives under `/var/folders/...`, + * but `/var` is a symlink to `/private/var`. A spawned CLI process's own + * `process.cwd()` reports the symlink-resolved path (that's how `getcwd(3)` + * works), so if `ctx.workspace` kept the unresolved form, any assertion built + * from it (e.g. an absolute path the CLI echoes back) would never match. + */ export async function createWorkspace(suite: string, opts: WorkspaceOptions = {}): Promise { const ws = join(opts.root ?? DEFAULT_ROOT, suite); await mkdir(ws, { recursive: true }); - return ws; + return await realpath(ws); } /** @@ -26,6 +34,7 @@ export async function createWorkspace(suite: string, opts: WorkspaceOptions = {} */ export async function copyFixtures(fixturesDir: string, workspace: string): Promise { const excludedConfig = join(fixturesDir, 'config'); + await cp(fixturesDir, workspace, { recursive: true, filter: (src) => src !== excludedConfig, diff --git a/tests/e2e/suites/__snapshots__/app.test.ts.snap b/tests/e2e/suites/__snapshots__/app.test.ts.snap new file mode 100644 index 000000000..5d090625d --- /dev/null +++ b/tests/e2e/suites/__snapshots__/app.test.ts.snap @@ -0,0 +1,17 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`app reports an identifier that is not in the Crowdin Store 1`] = `"■ Application with identifier 'crowdin-cli-e2e-no-such-app-xyz' doesn't exist in Crowdin Store"`; + +exports[`app fails to uninstall an application that is not installed 1`] = `"■ Failed to uninstall application 'crowdin-cli-e2e-no-such-app-xyz'. Application Not Found"`; + +exports[`app installs an application from the Crowdin Store 1`] = ` +"◆ Application has been installed +batch-add-strings Bulk Add Strings" +`; + +exports[`app uninstalls the application again 1`] = `"◆ Application has been uninstalled"`; + +exports[`app requires project_id even though no subcommand sends one 1`] = ` +"■ Configuration file is invalid. Check the following parameters in your configuration file: + - Required option 'project_id' is missing" +`; diff --git a/tests/e2e/suites/__snapshots__/auto-translate-mt.test.ts.snap b/tests/e2e/suites/__snapshots__/auto-translate-mt.test.ts.snap new file mode 100644 index 000000000..47da58c5b --- /dev/null +++ b/tests/e2e/suites/__snapshots__/auto-translate-mt.test.ts.snap @@ -0,0 +1,12 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`auto-translate via MT uploads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`auto-translate via MT requires --engine-id for the MT method 1`] = `"■ Machine Translation should be used with the '--engine-id' parameter"`; diff --git a/tests/e2e/suites/__snapshots__/auto-translate.test.ts.snap b/tests/e2e/suites/__snapshots__/auto-translate.test.ts.snap new file mode 100644 index 000000000..20117431f --- /dev/null +++ b/tests/e2e/suites/__snapshots__/auto-translate.test.ts.snap @@ -0,0 +1,12 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`auto-translate uploads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'sources' +◆ Directory 'sources/nested' +◆ File 'sources/app.xml' +◆ File 'sources/nested/extra.xml'" +`; diff --git a/tests/e2e/suites/__snapshots__/auto-update.test.ts.snap b/tests/e2e/suites/__snapshots__/auto-update.test.ts.snap new file mode 100644 index 000000000..9b140cc30 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/auto-update.test.ts.snap @@ -0,0 +1,31 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`auto update uploads sources, creating both files 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`auto update updates existing sources and creates a new one (auto-update is the default) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml' +◆ File '3_android.xml'" +`; + +exports[`auto update skips existing sources but still creates a new one with --no-auto-update 1`] = ` +"● Fetching project files +● Fetching project info +● File '1_android.xml' already exists and will not be updated +● File '2_android.xml' already exists and will not be updated +● File '3_android.xml' already exists and will not be updated +● Project files fetched +● Project info fetched +◆ File '4_android.xml'" +`; diff --git a/tests/e2e/suites/__snapshots__/base-path.test.ts.snap b/tests/e2e/suites/__snapshots__/base-path.test.ts.snap new file mode 100644 index 000000000..5c62c6537 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/base-path.test.ts.snap @@ -0,0 +1,138 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`base path uploads sources with an explicit --base-path, creating the directory hierarchy 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'files' +◆ Directory 'files/src' +◆ Directory 'files/src/main' +◆ Directory 'files/src/main/res' +◆ Directory 'files/src/main/res/values' +◆ File 'files/src/main/res/values/android.xml'" +`; + +exports[`base path updates the existing source file at the same base path 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'files/src/main/res/values/android.xml'" +`; + +exports[`base path uploads translations at the base path 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'files/src/main/res/values-it/android.xml' +● Importing translations for file 'files/src/main/res/values-uk/android.xml' +● Project files fetched +● Project info fetched +◆ File 'files/src/main/res/values-it/android.xml' +◆ File 'files/src/main/res/values-uk/android.xml'" +`; + +exports[`base path downloads translations at the base path 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'files/src/main/res/values-it/android.xml' extracted +◆ File 'files/src/main/res/values-uk/android.xml' extracted" +`; + +exports[`base path lists configured source files with --base-path 1`] = ` +"● Fetching project info +● Project info fetched +◆ files/src/main/res/values/android.xml" +`; + +exports[`base path lists configured translation files with --base-path 1`] = ` +"● Fetching project info +● Project info fetched +◆ files/src/main/res/values-it/android.xml +◆ files/src/main/res/values-uk/android.xml" +`; + +exports[`base path uploads sources to a new branch under a different base path 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'dev' +◆ Directory 'files' +◆ Directory 'files/src' +◆ Directory 'files/src/main' +◆ Directory 'files/src/main/res' +◆ Directory 'files/src/main/res/values' +◆ File 'files/src/main/res/values/android.xml'" +`; + +exports[`base path updates sources on the branch (branch already exists) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'files/src/main/res/values/android.xml'" +`; + +exports[`base path uploads translations on the branch 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'files/src/main/res/values-it/android.xml' +● Importing translations for file 'files/src/main/res/values-uk/android.xml' +● Project files fetched +● Project info fetched +◆ File 'files/src/main/res/values-it/android.xml' +◆ File 'files/src/main/res/values-uk/android.xml'" +`; + +exports[`base path downloads translations on the branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'files/src/main/res/values-it/android.xml' extracted +◆ File 'files/src/main/res/values-uk/android.xml' extracted" +`; + +exports[`base path uploads sources with a relative --base-path pointing into a subdirectory 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'src' +◆ Directory 'src/main' +◆ Directory 'src/main/res' +◆ Directory 'src/main/res/values' +◆ File 'src/main/res/values/android.xml'" +`; + +exports[`base path uploads translations with a relative --base-path 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'src/main/res/values-it/android.xml' +● Importing translations for file 'src/main/res/values-uk/android.xml' +● Project files fetched +● Project info fetched +◆ File 'src/main/res/values-it/android.xml' +◆ File 'src/main/res/values-uk/android.xml'" +`; + +exports[`base path downloads translations with a relative --base-path 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'src/main/res/values-it/android.xml' extracted +◆ File 'src/main/res/values-uk/android.xml' extracted" +`; diff --git a/e2e/suites/__snapshots__/basic-upload-download.test.ts.snap b/tests/e2e/suites/__snapshots__/basic-upload-download.test.ts.snap similarity index 60% rename from e2e/suites/__snapshots__/basic-upload-download.test.ts.snap rename to tests/e2e/suites/__snapshots__/basic-upload-download.test.ts.snap index 190747139..8ea88c37d 100644 --- a/e2e/suites/__snapshots__/basic-upload-download.test.ts.snap +++ b/tests/e2e/suites/__snapshots__/basic-upload-download.test.ts.snap @@ -5,10 +5,10 @@ exports[`basic upload sources and download translations uploads all source files ● Fetching project info ● Project files fetched ● Project info fetched -◆ Directory sources created -◆ File sources/alpha.md created -◆ File sources/beta.md created -◆ File sources/gamma.md created" +◆ Directory 'sources' +◆ File 'sources/alpha.md' +◆ File 'sources/beta.md' +◆ File 'sources/gamma.md'" `; exports[`basic upload sources and download translations updates existing source files 1`] = ` @@ -16,18 +16,23 @@ exports[`basic upload sources and download translations updates existing source ● Fetching project info ● Project files fetched ● Project info fetched -◆ File sources/alpha.md updated -◆ File sources/beta.md updated -◆ File sources/gamma.md updated" +◆ File 'sources/alpha.md' +◆ File 'sources/beta.md' +◆ File 'sources/gamma.md'" `; exports[`basic upload sources and download translations downloads translations for every target language 1`] = ` "● Building translations ● Building translations... ● Downloading translations -● Extracting archive ● Fetching project info ● Project info fetched ● Translations built -◆ Done" +◆ Done +◆ File 'translations/it-IT/alpha.md' extracted +◆ File 'translations/it-IT/beta.md' extracted +◆ File 'translations/it-IT/gamma.md' extracted +◆ File 'translations/uk-UA/alpha.md' extracted +◆ File 'translations/uk-UA/beta.md' extracted +◆ File 'translations/uk-UA/gamma.md' extracted" `; diff --git a/tests/e2e/suites/__snapshots__/branch.test.ts.snap b/tests/e2e/suites/__snapshots__/branch.test.ts.snap new file mode 100644 index 000000000..9afbb1d17 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/branch.test.ts.snap @@ -0,0 +1,24 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`branch lists the branch a new project starts with 1`] = `"◆ #id main"`; + +exports[`branch adds a branch 1`] = `"◆ #id main-line"`; + +exports[`branch clones a branch 1`] = ` +"● Cloning branch +● Cloning branch () +● Fetching project info +● Project info fetched +◆ #id cloned" +`; + +exports[`branch merges a branch, carrying its strings into the target 1`] = ` +"● Fetching project info +● Merging branch +● Merging branch () +● Project info fetched +◆ Merged branch 'to-merge' into 'main-line' + Merge summary: added: 1, deleted: 0, updated: 0, conflicted: 0" +`; + +exports[`branch deletes a branch 1`] = `"◆ Branch 'cloned' deleted"`; diff --git a/tests/e2e/suites/__snapshots__/branches.test.ts.snap b/tests/e2e/suites/__snapshots__/branches.test.ts.snap new file mode 100644 index 000000000..6d1ed563f --- /dev/null +++ b/tests/e2e/suites/__snapshots__/branches.test.ts.snap @@ -0,0 +1,103 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`branches uploads a single source file to a brand-new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test_list_string' +◆ File '1_android.xml'" +`; + +exports[`branches previews uploading two more source files to a not-yet-existing branch (dry run) 1`] = ` +"● Fetching project files +● Fetching project info +● File '1_android.xml' would be created +● File '2_android.xml' would be created +● Project files fetched +● Project info fetched" +`; + +exports[`branches previews the source upload dry run as a tree 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +. +├─ 1_android.xml +╰─ 2_android.xml" +`; + +exports[`branches uploads the two source files for real, creating the branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch' +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`branches updates the existing sources after local changes 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`branches previews the translation upload as a dry run 1`] = ` +"● Fetching project files +● Fetching project info +● File 'translations/it/1_android.xml' would be queued for translations import +● File 'translations/it/2_android.xml' would be queued for translations import +● File 'translations/uk/1_android.xml' would be queued for translations import +● File 'translations/uk/2_android.xml' would be queued for translations import +● Project files fetched +● Project info fetched" +`; + +exports[`branches previews the translation dry run as a tree 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +. +╰─ translations + ├─ it + │ ├─ 1_android.xml + │ ╰─ 2_android.xml + ╰─ uk + ├─ 1_android.xml + ╰─ 2_android.xml" +`; + +exports[`branches uploads translations for the updated sources 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/1_android.xml' +● Importing translations for file 'translations/it/2_android.xml' +● Importing translations for file 'translations/uk/1_android.xml' +● Importing translations for file 'translations/uk/2_android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/it/1_android.xml' +◆ File 'translations/it/2_android.xml' +◆ File 'translations/uk/1_android.xml' +◆ File 'translations/uk/2_android.xml'" +`; + +exports[`branches downloads translations for the branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; diff --git a/tests/e2e/suites/__snapshots__/bundle.test.ts.snap b/tests/e2e/suites/__snapshots__/bundle.test.ts.snap new file mode 100644 index 000000000..ffeba1ba6 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/bundle.test.ts.snap @@ -0,0 +1,28 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`bundle uploads sources for the bundle 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sample.json' +◆ File 'sample.xml'" +`; + +exports[`bundle adds a bundle 1`] = `"◆ #id macosx all.string RegularBundle"`; + +exports[`bundle adds a bundle with plain output 1`] = `" BundleCreatedWithPlainOutput"`; + +exports[`bundle downloads the bundle 1`] = ` +"● Building bundle +● Building bundle: 100% +● Building bundle: 100% +● Building bundle: 100% +◆ #id 'RegularBundle' has been successfully downloaded +◆ it/all.string +◆ uk/all.string" +`; + +exports[`bundle reports an empty bundle list 1`] = `"● No bundles found"`; + +exports[`bundle deletes a bundle 1`] = `"◆ Bundle #id deleted"`; diff --git a/tests/e2e/suites/__snapshots__/comment.test.ts.snap b/tests/e2e/suites/__snapshots__/comment.test.ts.snap new file mode 100644 index 000000000..245f26054 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/comment.test.ts.snap @@ -0,0 +1,50 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`comment reports no comments before any exist 1`] = `"● No comments found"`; + +exports[`comment uploads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'strings.xml'" +`; + +exports[`comment adds a plain comment 1`] = `"◆ #id Plain comment on welcome"`; + +exports[`comment adds an issue with a translation_mistake type 1`] = `"◆ #id Wrong translation of farewell"`; + +exports[`comment adds an issue with a source_mistake type 1`] = `"◆ #id Typo in the source of farewell"`; + +exports[`comment lists every comment 1`] = ` +"◆ #id Plain comment on welcome +◆ #id Typo in the source of farewell +◆ #id Wrong translation of farewell" +`; + +exports[`comment lists comments filtered by string id 1`] = `"◆ #id Plain comment on welcome"`; + +exports[`comment lists only issues when filtered by type 1`] = ` +"◆ #id Typo in the source of farewell +◆ #id Wrong translation of farewell" +`; + +exports[`comment infers the issue type when only --issue-type is given 1`] = `"◆ #id Wrong translation of farewell"`; + +exports[`comment infers the issue type when only --status is given 1`] = ` +"◆ #id Typo in the source of farewell +◆ #id Wrong translation of farewell" +`; + +exports[`comment lists comments with the verbose view 1`] = ` +"● Loading configuration from '/comment/crowdin.yml' file +◆ #id Typo in the source of farewell it source_mistake unresolved +◆ #id Wrong translation of farewell uk translation_mistake unresolved" +`; + +exports[`comment resolves a string issue 1`] = ` +"◆ A string issue #id has been successfully resolved +#id Wrong translation of farewell" +`; + +exports[`comment lists the resolved issue under the resolved status 1`] = `"◆ #id Wrong translation of farewell"`; diff --git a/tests/e2e/suites/__snapshots__/config.test.ts.snap b/tests/e2e/suites/__snapshots__/config.test.ts.snap new file mode 100644 index 000000000..349d7a36a --- /dev/null +++ b/tests/e2e/suites/__snapshots__/config.test.ts.snap @@ -0,0 +1,24 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`config lists the matched source files 1`] = ` +"● Fetching project info +● Project info fetched +◆ sources/main/app.xml +◆ sources/main/nested/deep.xml +◆ sources/other/lib.xml" +`; + +exports[`config renders the sources as a tree 1`] = ` +"● Fetching project info +● Project info fetched +. +╰─ sources + ├─ main + │ ├─ app.xml + │ ╰─ nested + │ ╰─ deep.xml + ╰─ other + ╰─ lib.xml" +`; + +exports[`config accepts a valid configuration 1`] = `"◆ Your configuration file looks good"`; diff --git a/tests/e2e/suites/__snapshots__/context.test.ts.snap b/tests/e2e/suites/__snapshots__/context.test.ts.snap new file mode 100644 index 000000000..d093d3a78 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/context.test.ts.snap @@ -0,0 +1,85 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`context uploads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'app.xml' +◆ File 'web.xml'" +`; + +exports[`context reports the coverage as a table 1`] = ` +"● Fetching project info +● Project info fetched +● Run 'crowdin context download --status=empty' to export strings needing context. +◆ Context Status for Project "e2e--context" (ID: ) +┌─────────────────────┬───────┬─────────┐ +│ │ Count │ Percent │ +├─────────────────────┼───────┼─────────┤ +│ Total strings │ 3 │ │ +│ With AI context │ 0 │ 0.00% │ +│ Without AI context │ 3 │ 100.00% │ +│ With manual context │ 1 │ 33.33% │ +└─────────────────────┴───────┴─────────┘" +`; + +exports[`context downloads every string to the default context file 1`] = ` +"● Fetching project info +● Project info fetched +◆ 'crowdin-context.jsonl' saved successfully +◆ Downloaded 3 strings" +`; + +exports[`context uploads nothing while every ai_context is empty 1`] = `"● No strings with AI context found in 'crowdin-context.jsonl'"`; + +exports[`context uploads the AI context 1`] = `"◆ Updated strings 3/3"`; + +exports[`context breaks the coverage down per file 1`] = ` +"● Fetching project info +● Project info fetched +● Run 'crowdin context download --status=empty' to export strings needing context. +◆ Context Status for Project "e2e--context" (ID: ) +┌──────────┬───────┬─────────────┬─────────┐ +│ File │ Total │ AI context │ Missing │ +├──────────┼───────┼─────────────┼─────────┤ +│ /app.xml │ 2 │ 2 (100.00%) │ 0 │ +│ /web.xml │ 1 │ 1 (100.00%) │ 0 │ +└──────────┴───────┴─────────────┴─────────┘" +`; + +exports[`context breaks the coverage down per file in the plain output 1`] = ` +"/app.xml - 0 +/app.xml - 100.00 +/app.xml - 2 +/app.xml - 2 +/web.xml - 0 +/web.xml - 1 +/web.xml - 1 +/web.xml - 100.00 +Missing: +Total strings: +With AI context (percentage): +With AI context:" +`; + +exports[`context reports the strings a filtered reset would clear under --dryrun 1`] = ` +"● Fetching project info +● Project info fetched +◆ Downloaded 1 strings +◆ String #id: Proceed to checkout (context: ) would be updated" +`; + +exports[`context clears the AI context of a filtered file only 1`] = ` +"● Fetching project info +● Project info fetched +◆ Downloaded 1 strings +◆ Updated strings 1/1" +`; + +exports[`context clears every remaining AI context under --all, keeping the manual context 1`] = ` +"● Fetching project info +● Project info fetched +◆ Downloaded 2 strings +◆ Updated strings 2/2" +`; diff --git a/tests/e2e/suites/__snapshots__/custom-language.test.ts.snap b/tests/e2e/suites/__snapshots__/custom-language.test.ts.snap new file mode 100644 index 000000000..cdaf56242 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/custom-language.test.ts.snap @@ -0,0 +1,39 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`custom language uploads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`custom language uploads translations for both the custom and standard target language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/dtk/1_android.xml' +● Importing translations for file 'translations/dtk/2_android.xml' +● Importing translations for file 'translations/uk/1_android.xml' +● Importing translations for file 'translations/uk/2_android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/dtk/1_android.xml' +◆ File 'translations/dtk/2_android.xml' +◆ File 'translations/uk/1_android.xml' +◆ File 'translations/uk/2_android.xml'" +`; + +exports[`custom language downloads translations for both the custom and standard target language 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/dtk/1_android.xml' extracted +◆ File 'translations/dtk/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; diff --git a/tests/e2e/suites/__snapshots__/custom-segmentation.test.ts.snap b/tests/e2e/suites/__snapshots__/custom-segmentation.test.ts.snap new file mode 100644 index 000000000..def0bd1fb --- /dev/null +++ b/tests/e2e/suites/__snapshots__/custom-segmentation.test.ts.snap @@ -0,0 +1,44 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`custom segmentation rejects an invalid SRX file for every source file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'sources'" +`; + +exports[`custom segmentation rejects an SRX file with an invalid regular expression 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched" +`; + +exports[`custom segmentation uploads sources once a valid SRX file is supplied 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/sample.docx' +◆ File 'sources/strings.xml'" +`; + +exports[`custom segmentation updates sources after the SRX rules change 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/sample.docx' +◆ File 'sources/strings.xml'" +`; + +exports[`custom segmentation uploads sources to a dest path and reflects the earlier SRX rules update on the original docx 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'Folder' +◆ File 'Folder/sample.docx' +◆ File 'Folder/strings.xml'" +`; diff --git a/tests/e2e/suites/__snapshots__/delete-obsolete.test.ts.snap b/tests/e2e/suites/__snapshots__/delete-obsolete.test.ts.snap new file mode 100644 index 000000000..b8f9f3904 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/delete-obsolete.test.ts.snap @@ -0,0 +1,73 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`delete obsolete uploads all sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'destination' +◆ Directory 'lang' +◆ File '1_android.xml' +◆ File '2_android.xml' +◆ File '3_android.xml' +◆ File 'destination/1_simple.csv' +◆ File 'lang/4_android.xml' +◆ File 'lang/en-US.json'" +`; + +exports[`delete obsolete deletes nothing for real with --delete-obsolete --dryrun 1`] = ` +"● '2_android.xml' file would be deleted +● Fetching project files +● Fetching project info +● File '1_android.xml' would be updated +● File '3_android.xml' would be updated +● File 'lang/4_android.xml' would be updated +● File 'lang/en-US.json' would be updated +● Project files fetched +● Project info fetched" +`; + +exports[`delete obsolete deletes obsolete files and directories for real with --delete-obsolete 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ '2_android.xml' file was deleted +◆ '3_android.xml' file was deleted +◆ 'lang/' directory was deleted +◆ 'lang/4_android.xml' file was deleted +◆ 'lang/en-US.json' file was deleted +◆ File '1_android.xml' +◆ File 'destination/1_simple.csv'" +`; + +exports[`delete obsolete deletes an obsolete file whose remaining sibling now has a dest 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ '1_android.xml' file was deleted +◆ 'destination/1_simple.csv' file was deleted +◆ File 'destination/1_android.xml' +◆ No obsolete directories found" +`; + +exports[`delete obsolete nothing to delete when the dest remap makes local and remote paths coincide (dryrun) 1`] = ` +"● Fetching project files +● Fetching project info +● File 'other_destination/1_android.xml' would be updated +● File 'other_destination/2_android.xml' would be updated +● Project files fetched +● Project info fetched" +`; + +exports[`delete obsolete reports the steady state once local and remote paths already coincide 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'other_destination/1_android.xml' +◆ File 'other_destination/2_android.xml' +◆ No obsolete directories found +◆ No obsolete files were found" +`; diff --git a/tests/e2e/suites/__snapshots__/dest.test.ts.snap b/tests/e2e/suites/__snapshots__/dest.test.ts.snap new file mode 100644 index 000000000..d97b51922 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/dest.test.ts.snap @@ -0,0 +1,157 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`dest uploads sources across dest-remapped file groups 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'Folder' +◆ Directory 'Test-destCheckFolder' +◆ Directory 'Test-destCheckFolder/xml' +◆ Directory 'Test-destCheckFolder/xml/android' +◆ Directory 'Test-destCheckFolderParallelFileProcess' +◆ Directory 'Test-destCheckFolderParallelFileProcess/xml' +◆ File 'Android.xml' +◆ File 'Folder/Android.xml' +◆ File 'Folder/Client.xml' +◆ File 'Test-destCheckFolder/xml/android/android.xml' +◆ File 'Test-destCheckFolderParallelFileProcess/xml/android.xml' +◆ File 'Test-destCheckFolderParallelFileProcess/xml/second_android.xml'" +`; + +exports[`dest uploads translations across dest-remapped file groups 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'android_it_IT.xml' +● Importing translations for file 'android_uk_UA.xml' +● Importing translations for file 'destCheckFolder/android_it_IT.xml' +● Importing translations for file 'destCheckFolder/android_uk_UA.xml' +● Importing translations for file 'destCheckFolderParallelFileProcess/android_it_IT.xml' +● Importing translations for file 'destCheckFolderParallelFileProcess/android_uk_UA.xml' +● Importing translations for file 'destCheckFolderParallelFileProcess/second_android_it_IT.xml' +● Importing translations for file 'destCheckFolderParallelFileProcess/second_android_uk_UA.xml' +● Importing translations for file 'folder/android_it_IT.xml' +● Importing translations for file 'folder/android_uk_UA.xml' +● Importing translations for file 'folder/client_it_IT.xml' +● Importing translations for file 'folder/client_uk_UA.xml' +● Project files fetched +● Project info fetched +◆ File 'android_it_IT.xml' +◆ File 'android_uk_UA.xml' +◆ File 'destCheckFolder/android_it_IT.xml' +◆ File 'destCheckFolder/android_uk_UA.xml' +◆ File 'destCheckFolderParallelFileProcess/android_it_IT.xml' +◆ File 'destCheckFolderParallelFileProcess/android_uk_UA.xml' +◆ File 'destCheckFolderParallelFileProcess/second_android_it_IT.xml' +◆ File 'destCheckFolderParallelFileProcess/second_android_uk_UA.xml' +◆ File 'folder/android_it_IT.xml' +◆ File 'folder/android_uk_UA.xml' +◆ File 'folder/client_it_IT.xml' +◆ File 'folder/client_uk_UA.xml'" +`; + +exports[`dest downloads translations and matches the uploaded content for the parallel file group 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'android_it_IT.xml' extracted +◆ File 'android_uk_UA.xml' extracted +◆ File 'destCheckFolder/android_it_IT.xml' extracted +◆ File 'destCheckFolder/android_uk_UA.xml' extracted +◆ File 'destCheckFolderParallelFileProcess/android_it_IT.xml' extracted +◆ File 'destCheckFolderParallelFileProcess/android_uk_UA.xml' extracted +◆ File 'destCheckFolderParallelFileProcess/second_android_it_IT.xml' extracted +◆ File 'destCheckFolderParallelFileProcess/second_android_uk_UA.xml' extracted +◆ File 'folder/android_it_IT.xml' extracted +◆ File 'folder/android_uk_UA.xml' extracted +◆ File 'folder/client_it_IT.xml' extracted +◆ File 'folder/client_uk_UA.xml' extracted" +`; + +exports[`dest uploads sources to a branch with dest remapping 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch' +◆ Directory 'Folder' +◆ Directory 'Test-destCheckFolder' +◆ Directory 'Test-destCheckFolder/xml' +◆ Directory 'Test-destCheckFolder/xml/android' +◆ Directory 'Test-destCheckFolderParallelFileProcess' +◆ Directory 'Test-destCheckFolderParallelFileProcess/xml' +◆ File 'Android.xml' +◆ File 'Folder/Android.xml' +◆ File 'Folder/Client.xml' +◆ File 'Test-destCheckFolder/xml/android/android.xml' +◆ File 'Test-destCheckFolderParallelFileProcess/xml/android.xml' +◆ File 'Test-destCheckFolderParallelFileProcess/xml/second_android.xml'" +`; + +exports[`dest uploads translations to the branch 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'android_it_IT.xml' +● Importing translations for file 'android_uk_UA.xml' +● Importing translations for file 'destCheckFolder/android_it_IT.xml' +● Importing translations for file 'destCheckFolder/android_uk_UA.xml' +● Importing translations for file 'destCheckFolderParallelFileProcess/android_it_IT.xml' +● Importing translations for file 'destCheckFolderParallelFileProcess/android_uk_UA.xml' +● Importing translations for file 'destCheckFolderParallelFileProcess/second_android_it_IT.xml' +● Importing translations for file 'destCheckFolderParallelFileProcess/second_android_uk_UA.xml' +● Importing translations for file 'folder/android_it_IT.xml' +● Importing translations for file 'folder/android_uk_UA.xml' +● Importing translations for file 'folder/client_it_IT.xml' +● Importing translations for file 'folder/client_uk_UA.xml' +● Project files fetched +● Project info fetched +◆ File 'android_it_IT.xml' +◆ File 'android_uk_UA.xml' +◆ File 'destCheckFolder/android_it_IT.xml' +◆ File 'destCheckFolder/android_uk_UA.xml' +◆ File 'destCheckFolderParallelFileProcess/android_it_IT.xml' +◆ File 'destCheckFolderParallelFileProcess/android_uk_UA.xml' +◆ File 'destCheckFolderParallelFileProcess/second_android_it_IT.xml' +◆ File 'destCheckFolderParallelFileProcess/second_android_uk_UA.xml' +◆ File 'folder/android_it_IT.xml' +◆ File 'folder/android_uk_UA.xml' +◆ File 'folder/client_it_IT.xml' +◆ File 'folder/client_uk_UA.xml'" +`; + +exports[`dest downloads translations from the branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'android_it_IT.xml' extracted +◆ File 'android_uk_UA.xml' extracted +◆ File 'destCheckFolder/android_it_IT.xml' extracted +◆ File 'destCheckFolder/android_uk_UA.xml' extracted +◆ File 'destCheckFolderParallelFileProcess/android_it_IT.xml' extracted +◆ File 'destCheckFolderParallelFileProcess/android_uk_UA.xml' extracted +◆ File 'destCheckFolderParallelFileProcess/second_android_it_IT.xml' extracted +◆ File 'destCheckFolderParallelFileProcess/second_android_uk_UA.xml' extracted +◆ File 'folder/android_it_IT.xml' extracted +◆ File 'folder/android_uk_UA.xml' extracted +◆ File 'folder/client_it_IT.xml' extracted +◆ File 'folder/client_uk_UA.xml' extracted" +`; + +exports[`dest uploads a single file with an explicit --dest and no config file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'SingleDest' +◆ Directory 'SingleDest/xml' +◆ Directory 'SingleDest/xml/android' +◆ File 'SingleDest/xml/android/android.xml'" +`; diff --git a/tests/e2e/suites/__snapshots__/distribution.test.ts.snap b/tests/e2e/suites/__snapshots__/distribution.test.ts.snap new file mode 100644 index 000000000..03cf3d4e8 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/distribution.test.ts.snap @@ -0,0 +1,7 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`distribution reports a project with no distributions 1`] = `"● No distributions found"`; + +exports[`distribution adds a distribution for a bundle 1`] = `"◆ D1 bundle"`; + +exports[`distribution lists the distribution with its hash and export mode 1`] = `"◆ D1 bundle"`; diff --git a/tests/e2e/suites/__snapshots__/download-pseudo.test.ts.snap b/tests/e2e/suites/__snapshots__/download-pseudo.test.ts.snap new file mode 100644 index 000000000..798ac9424 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/download-pseudo.test.ts.snap @@ -0,0 +1,103 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`download pseudo uploads the single source file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`download pseudo downloads pseudo translations with all parameters (cyrillic transformation) 1`] = ` +"● Building pseudo translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/uk/android.xml' extracted" +`; + +exports[`download pseudo downloads pseudo translations with asian character transformation 1`] = ` +"● Building pseudo translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/zh/android.xml' extracted" +`; + +exports[`download pseudo downloads pseudo translations with european character transformation 1`] = ` +"● Building pseudo translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/fr/android.xml' extracted" +`; + +exports[`download pseudo downloads pseudo translations with arabic character transformation 1`] = ` +"● Building pseudo translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/ar/android.xml' extracted" +`; + +exports[`download pseudo downloads pseudo translations with length correction only 1`] = ` +"● Building pseudo translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/en/android.xml' extracted" +`; + +exports[`download pseudo downloads pseudo translations with prefix only 1`] = ` +"● Building pseudo translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/en/android.xml' extracted" +`; + +exports[`download pseudo downloads pseudo translations with suffix only 1`] = ` +"● Building pseudo translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/en/android.xml' extracted" +`; + +exports[`download pseudo downloads pseudo translations using default settings when pseudo_localization is absent 1`] = ` +"● Building pseudo translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/en/android.xml' extracted" +`; + +exports[`download pseudo rejects an unknown character_transformation value 1`] = `""`; + +exports[`download pseudo rejects a prefix of the wrong type 1`] = `""`; + +exports[`download pseudo rejects a length_correction outside the -50..100 range 1`] = `""`; diff --git a/tests/e2e/suites/__snapshots__/download-sources.test.ts.snap b/tests/e2e/suites/__snapshots__/download-sources.test.ts.snap new file mode 100644 index 000000000..78ac3f478 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/download-sources.test.ts.snap @@ -0,0 +1,101 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`download sources uploads all nested source files to the project 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'folder_2' +◆ Directory 'root' +◆ Directory 'root/folder_1' +◆ Directory 'root/folder_1/f1' +◆ Directory 'root/folder_1/f1/f2' +◆ File 'folder_2/android_1.xml' +◆ File 'folder_2/android_2.xml' +◆ File 'folder_2/android_3.xml' +◆ File 'folder_2/android_4a.xml' +◆ File 'root/folder_1/android.xml' +◆ File 'root/folder_1/f1/android.xml' +◆ File 'root/folder_1/f1/f2/android.xml'" +`; + +exports[`download sources uploads the same nested source files to a brand-new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'b1' +◆ Directory 'folder_2' +◆ Directory 'root' +◆ Directory 'root/folder_1' +◆ Directory 'root/folder_1/f1' +◆ Directory 'root/folder_1/f1/f2' +◆ File 'folder_2/android_1.xml' +◆ File 'folder_2/android_2.xml' +◆ File 'folder_2/android_3.xml' +◆ File 'folder_2/android_4a.xml' +◆ File 'root/folder_1/android.xml' +◆ File 'root/folder_1/f1/android.xml' +◆ File 'root/folder_1/f1/f2/android.xml'" +`; + +exports[`download sources downloads sources back to their original local paths 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'folder_2/android_1.xml' +◆ File 'folder_2/android_2.xml' +◆ File 'folder_2/android_3.xml' +◆ File 'folder_2/android_4a.xml' +◆ File 'root/folder_1/android.xml' +◆ File 'root/folder_1/f1/android.xml' +◆ File 'root/folder_1/f1/f2/android.xml'" +`; + +exports[`download sources downloads sources again with --output plain 1`] = ` +"folder_2/android_1.xml +folder_2/android_2.xml +folder_2/android_3.xml +folder_2/android_4a.xml +root/folder_1/android.xml +root/folder_1/f1/android.xml +root/folder_1/f1/f2/android.xml" +`; + +exports[`download sources downloads sources from the b1 branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'folder_2/android_1.xml' +◆ File 'folder_2/android_2.xml' +◆ File 'folder_2/android_3.xml' +◆ File 'folder_2/android_4a.xml' +◆ File 'root/folder_1/android.xml' +◆ File 'root/folder_1/f1/android.xml' +◆ File 'root/folder_1/f1/f2/android.xml'" +`; + +exports[`download sources warns when a source pattern matches nothing 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched" +`; + +exports[`download sources rejects --reviewed on a non-Enterprise (SaaS) account 1`] = `""`; + +exports[`download sources previews the download without writing anything with --dryrun 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ folder_2/android_1.xml +◆ folder_2/android_2.xml +◆ folder_2/android_3.xml +◆ folder_2/android_4a.xml +◆ root/folder_1/android.xml +◆ root/folder_1/f1/android.xml +◆ root/folder_1/f1/f2/android.xml" +`; diff --git a/tests/e2e/suites/__snapshots__/download-translations-all.test.ts.snap b/tests/e2e/suites/__snapshots__/download-translations-all.test.ts.snap new file mode 100644 index 000000000..10371e248 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/download-translations-all.test.ts.snap @@ -0,0 +1,153 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`download translations --all uploads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'root' +◆ Directory 'root/folder' +◆ Directory 'root/{{cookiecutter.module_name}}' +◆ File 'root/android.xml' +◆ File 'root/folder/android.xml' +◆ File 'root/{{cookiecutter.module_name}}/android.xml'" +`; + +exports[`download translations --all previews downloading all translations (dry run) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ translations/it/android.xml +◆ translations/it/folder/android.xml +◆ translations/it/{{cookiecutter.module_name}}/android.xml +◆ translations/uk/android.xml +◆ translations/uk/folder/android.xml +◆ translations/uk/{{cookiecutter.module_name}}/android.xml" +`; + +exports[`download translations --all downloads all translations 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/it/folder/android.xml' extracted +◆ File 'translations/it/{{cookiecutter.module_name}}/android.xml' extracted +◆ File 'translations/uk/android.xml' extracted +◆ File 'translations/uk/folder/android.xml' extracted +◆ File 'translations/uk/{{cookiecutter.module_name}}/android.xml' extracted" +`; + +exports[`download translations --all uploads sources to a new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'b1' +◆ Directory 'root' +◆ Directory 'root/folder' +◆ Directory 'root/{{cookiecutter.module_name}}' +◆ File 'root/android.xml' +◆ File 'root/folder/android.xml' +◆ File 'root/{{cookiecutter.module_name}}/android.xml'" +`; + +exports[`download translations --all downloads translations for the branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/it/folder/android.xml' extracted +◆ File 'translations/it/{{cookiecutter.module_name}}/android.xml' extracted +◆ File 'translations/uk/android.xml' extracted +◆ File 'translations/uk/folder/android.xml' extracted +◆ File 'translations/uk/{{cookiecutter.module_name}}/android.xml' extracted" +`; + +exports[`download translations --all reports an empty archive when skipping untranslated files 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done" +`; + +exports[`download translations --all keeps the downloaded archive for the branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +◆ Archive saved to '/files/crowdin-translations.zip' +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/it/folder/android.xml' extracted +◆ File 'translations/it/{{cookiecutter.module_name}}/android.xml' extracted +◆ File 'translations/uk/android.xml' extracted +◆ File 'translations/uk/folder/android.xml' extracted +◆ File 'translations/uk/{{cookiecutter.module_name}}/android.xml' extracted" +`; + +exports[`download translations --all deletes the branch 1`] = `"◆ Branch 'b1' deleted"`; + +exports[`download translations --all keeps the downloaded archive 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +◆ Archive saved to '/files/crowdin-translations.zip' +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/it/folder/android.xml' extracted +◆ File 'translations/it/{{cookiecutter.module_name}}/android.xml' extracted +◆ File 'translations/uk/android.xml' extracted +◆ File 'translations/uk/folder/android.xml' extracted +◆ File 'translations/uk/{{cookiecutter.module_name}}/android.xml' extracted" +`; + +exports[`download translations --all keeps the downloaded archive for a single language 1`] = ` +"● Building translations for languages: uk +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +◆ Archive saved to '/files/crowdin-translations.zip' +◆ Done +◆ File 'translations/uk/android.xml' extracted +◆ File 'translations/uk/folder/android.xml' extracted +◆ File 'translations/uk/{{cookiecutter.module_name}}/android.xml' extracted" +`; + +exports[`download translations --all keeps the downloaded archive with plain output 1`] = ` +"crowdin-translations.zip +translations/it/android.xml +translations/it/folder/android.xml +translations/it/{{cookiecutter.module_name}}/android.xml +translations/uk/android.xml +translations/uk/folder/android.xml +translations/uk/{{cookiecutter.module_name}}/android.xml" +`; diff --git a/tests/e2e/suites/__snapshots__/env-variables.test.ts.snap b/tests/e2e/suites/__snapshots__/env-variables.test.ts.snap new file mode 100644 index 000000000..e1eb86540 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/env-variables.test.ts.snap @@ -0,0 +1,48 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`env variables uploads sources using credentials read from an env file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`env variables uploads translations using credentials read from an env file 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/1_android.xml' +● Importing translations for file 'translations/it/2_android.xml' +● Importing translations for file 'translations/uk/1_android.xml' +● Importing translations for file 'translations/uk/2_android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/it/1_android.xml' +◆ File 'translations/it/2_android.xml' +◆ File 'translations/uk/1_android.xml' +◆ File 'translations/uk/2_android.xml'" +`; + +exports[`env variables downloads translations using credentials read from an env file 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`env variables a process-level env var overrides an invalid token in the env file on re-upload 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; diff --git a/tests/e2e/suites/__snapshots__/excluded-languages.test.ts.snap b/tests/e2e/suites/__snapshots__/excluded-languages.test.ts.snap new file mode 100644 index 000000000..99f896e64 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/excluded-languages.test.ts.snap @@ -0,0 +1,181 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`excluded languages uploads sources excluding a language via the CLI flag 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`excluded languages uploads translations, skipping the language with no local files 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/1_android.xml' +● Importing translations for file 'translations/it/2_android.xml' +● Importing translations for file 'translations/uk/1_android.xml' +● Importing translations for file 'translations/uk/2_android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/it/1_android.xml' +◆ File 'translations/it/2_android.xml' +◆ File 'translations/uk/1_android.xml' +◆ File 'translations/uk/2_android.xml'" +`; + +exports[`excluded languages lists configured translation files for every target language regardless of exclusions 1`] = ` +"● Fetching project info +● Project info fetched +◆ translations/de/1_android.xml +◆ translations/de/2_android.xml +◆ translations/it/1_android.xml +◆ translations/it/2_android.xml +◆ translations/uk/1_android.xml +◆ translations/uk/2_android.xml" +`; + +exports[`excluded languages downloads translations, building only the non-excluded languages 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`excluded languages changes the excluded language via a new CLI flag value 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`excluded languages re-uploads translations, rejecting the newly excluded language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/1_android.xml' +● Importing translations for file 'translations/it/2_android.xml' +● Importing translations for file 'translations/uk/1_android.xml' +● Importing translations for file 'translations/uk/2_android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/uk/1_android.xml' +◆ File 'translations/uk/2_android.xml'" +`; + +exports[`excluded languages downloads translations after the excluded language changed 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/de/1_android.xml' extracted +◆ File 'translations/de/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`excluded languages uploads sources without the CLI flag, leaving the exclusion unchanged 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`excluded languages downloads translations, exclusion unchanged since the flag was omitted 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/de/1_android.xml' extracted +◆ File 'translations/de/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`excluded languages uploads sources with exclusion declared in the config file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`excluded languages downloads translations honoring the config-declared exclusion 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/de/1_android.xml' extracted +◆ File 'translations/de/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`excluded languages uploads sources merging the config exclusion with the CLI flag 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`excluded languages downloads translations honoring the merged config+CLI exclusion 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`excluded languages uploads sources with per-file exclusions across two file groups 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`excluded languages downloads translations honoring per-file exclusions 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/de/1_android.xml' extracted +◆ File 'translations/de/2_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted" +`; + +exports[`excluded languages rejects an excluded language that does not exist in the project 1`] = ` +"● Fetching project info +● Project info fetched" +`; diff --git a/tests/e2e/suites/__snapshots__/export-options.test.ts.snap b/tests/e2e/suites/__snapshots__/export-options.test.ts.snap new file mode 100644 index 000000000..f24b37fdb --- /dev/null +++ b/tests/e2e/suites/__snapshots__/export-options.test.ts.snap @@ -0,0 +1,226 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`export options uploads sources for both files 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '1_android.xml' +◆ File '2_android.xml'" +`; + +exports[`export options reports no fully translated files when skipping untranslated files via the CLI flag, before any translations exist 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done" +`; + +exports[`export options reports no fully translated files when skip_untranslated_files is set in config, before any translations exist 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done" +`; + +exports[`export options uploads translations for both languages 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/1_android.xml' +● Importing translations for file 'translations/it/2_android.xml' +● Importing translations for file 'translations/uk/1_android.xml' +● Importing translations for file 'translations/uk/2_android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/it/1_android.xml' +◆ File 'translations/it/2_android.xml' +◆ File 'translations/uk/1_android.xml' +◆ File 'translations/uk/2_android.xml'" +`; + +exports[`export options approves translations by re-uploading the approved fixtures with auto-approve 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/1_android.xml' +● Importing translations for file 'translations/it/2_android.xml' +● Importing translations for file 'translations/uk/1_android.xml' +● Importing translations for file 'translations/uk/2_android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/it/1_android.xml' +◆ File 'translations/it/2_android.xml' +◆ File 'translations/uk/1_android.xml' +◆ File 'translations/uk/2_android.xml'" +`; + +exports[`export options downloads translations skipping untranslated strings via the CLI flag 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`export options downloads translations skipping untranslated files via the CLI flag 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted" +`; + +exports[`export options downloads translations exporting only approved translations via the CLI flag 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`export options downloads translations skipping untranslated strings and exporting only approved via CLI flags 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`export options downloads translations skipping untranslated files and exporting only approved via CLI flags 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/uk/1_android.xml' extracted" +`; + +exports[`export options rejects skipping untranslated strings and files at the same time 1`] = ` +"● Building translations +● Fetching project info +● Project info fetched" +`; + +exports[`export options downloads translations with skip_untranslated_strings set in config 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`export options downloads translations with skip_untranslated_files set in config 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted" +`; + +exports[`export options downloads translations with export_only_approved set in config 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`export options downloads translations with skip_untranslated_strings and export_only_approved set in config 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`export options downloads translations with skip_untranslated_files and export_only_approved set in config 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/uk/1_android.xml' extracted" +`; + +exports[`export options warns and ignores export_strings_that_passed_workflow outside Enterprise 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/it/2_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted +◆ File 'translations/uk/2_android.xml' extracted" +`; + +exports[`export options downloads translations across two file groups with different export options in config 1`] = ` +"● Building translations +● Building translations... +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted" +`; diff --git a/tests/e2e/suites/__snapshots__/file-groups.test.ts.snap b/tests/e2e/suites/__snapshots__/file-groups.test.ts.snap new file mode 100644 index 000000000..89c101211 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/file-groups.test.ts.snap @@ -0,0 +1,44 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`file groups uploads sources across overlapping file groups, then reports the empty pattern 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'sources' +◆ File 'sources/android.xml' +◆ File 'sources/java.properties'" +`; + +exports[`file groups uploads translations, duplicating the file matched by both overlapping groups 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/android.xml' +● Importing translations for file 'translations/it/android.xml' +● Importing translations for file 'translations/it/java.properties' +● Importing translations for file 'translations/uk/android.xml' +● Importing translations for file 'translations/uk/android.xml' +● Importing translations for file 'translations/uk/java.properties' +● Project files fetched +● Project info fetched +◆ File 'translations/it/android.xml' +◆ File 'translations/it/android.xml' +◆ File 'translations/it/java.properties' +◆ File 'translations/uk/android.xml' +◆ File 'translations/uk/android.xml' +◆ File 'translations/uk/java.properties'" +`; + +exports[`file groups downloads translations, deduplicating the file matched by both overlapping groups 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/it/java.properties' extracted +◆ File 'translations/uk/android.xml' extracted +◆ File 'translations/uk/java.properties' extracted" +`; diff --git a/tests/e2e/suites/__snapshots__/file-tree.test.ts.snap b/tests/e2e/suites/__snapshots__/file-tree.test.ts.snap new file mode 100644 index 000000000..68c79e607 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/file-tree.test.ts.snap @@ -0,0 +1,308 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`file tree previews uploading sources as a dry run 1`] = ` +"● Fetching project files +● Fetching project info +● File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' would be created +● File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' would be created +● File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' would be created +● File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' would be created +● File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' would be created +● Project files fetched +● Project info fetched" +`; + +exports[`file tree uploads sources, creating the full nested directory hierarchy 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'php' +◆ Directory 'php/hudson.php' +◆ Directory 'php/hudson.php/src' +◆ Directory 'php/hudson.php/src/org' +◆ Directory 'php/hudson.php/src/org/netbeans' +◆ Directory 'php/hudson.php/src/org/netbeans/modules' +◆ Directory 'php/hudson.php/src/org/netbeans/modules/hudson' +◆ Directory 'php/hudson.php/src/org/netbeans/modules/hudson/php' +◆ Directory 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources' +◆ Directory 'php/libs.javacup' +◆ Directory 'php/libs.javacup/src' +◆ Directory 'php/libs.javacup/src/org' +◆ Directory 'php/libs.javacup/src/org/netbeans' +◆ Directory 'php/libs.javacup/src/org/netbeans/libs' +◆ Directory 'php/libs.javacup/src/org/netbeans/libs/javacup' +◆ Directory 'php/php.api.annotation' +◆ Directory 'php/php.api.annotation/src' +◆ Directory 'php/php.api.annotation/src/org' +◆ Directory 'php/php.api.annotation/src/org/netbeans' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules/php' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules/php/api' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources' +◆ Directory 'php/php.api.documentation' +◆ Directory 'php/php.api.documentation/src' +◆ Directory 'php/php.api.documentation/src/org' +◆ Directory 'php/php.api.documentation/src/org/netbeans' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules/php' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules/php/api' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources' +◆ Directory 'php/php.api.editor' +◆ Directory 'php/php.api.editor/src' +◆ Directory 'php/php.api.editor/src/org' +◆ Directory 'php/php.api.editor/src/org/netbeans' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules/php' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules/php/api' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules/php/api/editor' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources' +◆ File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'" +`; + +exports[`file tree updates the existing sources (no new directories) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'" +`; + +exports[`file tree uploads translations for a single language (uk) 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +● Importing translations for file 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +● Importing translations for file 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +● Importing translations for file 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +● Importing translations for file 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' +● Project files fetched +● Project info fetched +◆ File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'" +`; + +exports[`file tree uploads translations for all languages 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +● Importing translations for file 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +● Importing translations for file 'it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +● Importing translations for file 'it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +● Importing translations for file 'it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' +● Importing translations for file 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +● Importing translations for file 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +● Importing translations for file 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +● Importing translations for file 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +● Importing translations for file 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' +● Project files fetched +● Project info fetched +◆ File 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' +◆ File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'" +`; + +exports[`file tree previews downloading translations as a dry run 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties +◆ it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties +◆ it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties +◆ it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties +◆ it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties +◆ uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties +◆ uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties +◆ uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties +◆ uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties +◆ uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties" +`; + +exports[`file tree downloads translations, overwriting the local it/uk trees 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' extracted +◆ File 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' extracted +◆ File 'it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' extracted +◆ File 'it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' extracted +◆ File 'it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' extracted +◆ File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' extracted +◆ File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' extracted +◆ File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' extracted +◆ File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' extracted +◆ File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' extracted" +`; + +exports[`file tree uploads sources to a brand-new branch, creating the directory hierarchy again 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'branch1' +◆ Directory 'php' +◆ Directory 'php/hudson.php' +◆ Directory 'php/hudson.php/src' +◆ Directory 'php/hudson.php/src/org' +◆ Directory 'php/hudson.php/src/org/netbeans' +◆ Directory 'php/hudson.php/src/org/netbeans/modules' +◆ Directory 'php/hudson.php/src/org/netbeans/modules/hudson' +◆ Directory 'php/hudson.php/src/org/netbeans/modules/hudson/php' +◆ Directory 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources' +◆ Directory 'php/libs.javacup' +◆ Directory 'php/libs.javacup/src' +◆ Directory 'php/libs.javacup/src/org' +◆ Directory 'php/libs.javacup/src/org/netbeans' +◆ Directory 'php/libs.javacup/src/org/netbeans/libs' +◆ Directory 'php/libs.javacup/src/org/netbeans/libs/javacup' +◆ Directory 'php/php.api.annotation' +◆ Directory 'php/php.api.annotation/src' +◆ Directory 'php/php.api.annotation/src/org' +◆ Directory 'php/php.api.annotation/src/org/netbeans' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules/php' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules/php/api' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation' +◆ Directory 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources' +◆ Directory 'php/php.api.documentation' +◆ Directory 'php/php.api.documentation/src' +◆ Directory 'php/php.api.documentation/src/org' +◆ Directory 'php/php.api.documentation/src/org/netbeans' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules/php' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules/php/api' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation' +◆ Directory 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources' +◆ Directory 'php/php.api.editor' +◆ Directory 'php/php.api.editor/src' +◆ Directory 'php/php.api.editor/src/org' +◆ Directory 'php/php.api.editor/src/org/netbeans' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules/php' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules/php/api' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules/php/api/editor' +◆ Directory 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources' +◆ File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'" +`; + +exports[`file tree updates sources on the branch (branch already exists) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'" +`; + +exports[`file tree uploads translations for a single language (uk) on the branch 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +● Importing translations for file 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +● Importing translations for file 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +● Importing translations for file 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +● Importing translations for file 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' +● Project files fetched +● Project info fetched +◆ File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'" +`; + +exports[`file tree uploads translations for all languages on the branch 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +● Importing translations for file 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +● Importing translations for file 'it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +● Importing translations for file 'it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +● Importing translations for file 'it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' +● Importing translations for file 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +● Importing translations for file 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +● Importing translations for file 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +● Importing translations for file 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +● Importing translations for file 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' +● Project files fetched +● Project info fetched +◆ File 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' +◆ File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' +◆ File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' +◆ File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' +◆ File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' +◆ File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'" +`; + +exports[`file tree previews downloading translations on the branch as a dry run 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties +◆ it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties +◆ it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties +◆ it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties +◆ it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties +◆ uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties +◆ uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties +◆ uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties +◆ uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties +◆ uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties" +`; + +exports[`file tree downloads translations on the branch, overwriting the local it/uk trees again 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' extracted +◆ File 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' extracted +◆ File 'it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' extracted +◆ File 'it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' extracted +◆ File 'it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' extracted +◆ File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' extracted +◆ File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' extracted +◆ File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' extracted +◆ File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' extracted +◆ File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' extracted" +`; diff --git a/tests/e2e/suites/__snapshots__/file-type.test.ts.snap b/tests/e2e/suites/__snapshots__/file-type.test.ts.snap new file mode 100644 index 000000000..cbcbbde11 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/file-type.test.ts.snap @@ -0,0 +1,65 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`file type type "android6" is stored as android with parserVersion 6 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`file type type "android8" is stored as android with parserVersion 8 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`file type type "android" is normalized to parserVersion 11 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`file type type "android5" is stored as android with parserVersion 5 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`file type type "android4" is stored as android with parserVersion 4 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`file type type "android3" is stored as android with parserVersion 3 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`file type type "android2" is stored as android with parserVersion 2 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`file type type "android1" is normalized to parserVersion 1 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; diff --git a/tests/e2e/suites/__snapshots__/file.test.ts.snap b/tests/e2e/suites/__snapshots__/file.test.ts.snap new file mode 100644 index 000000000..8dff1e6c4 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/file.test.ts.snap @@ -0,0 +1,44 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`file uploads a file, creating its directory 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ #id sources/app.xml +◆ Directory 'sources'" +`; + +exports[`file skips an existing file with --no-auto-update 1`] = ` +"● Fetching project files +● Fetching project info +● Project already contains the file 'sources/app.xml' +● Project files fetched +● Project info fetched" +`; + +exports[`file uploads a file into a branch it creates 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ #id feature/sources/extra.xml +◆ Branch 'feature' +◆ Directory 'feature/sources'" +`; + +exports[`file downloads a source file back to its own path 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '/sources/app.xml'" +`; + +exports[`file deletes a file inside a branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File '/sources/extra.xml' deleted" +`; diff --git a/tests/e2e/suites/__snapshots__/full-cli-workflow.test.ts.snap b/tests/e2e/suites/__snapshots__/full-cli-workflow.test.ts.snap new file mode 100644 index 000000000..8a8013f6e --- /dev/null +++ b/tests/e2e/suites/__snapshots__/full-cli-workflow.test.ts.snap @@ -0,0 +1,265 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`full CLI project workflow previews the source upload 1`] = ` +"● Fetching project files +● Fetching project info +● File 'sources/alpha.md' would be created +● File 'sources/beta.md' would be created +● File 'sources/gamma.md' would be created +● Project files fetched +● Project info fetched" +`; + +exports[`full CLI project workflow previews the source upload as a tree 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +. +╰─ sources + ├─ alpha.md + ├─ beta.md + ╰─ gamma.md" +`; + +exports[`full CLI project workflow previews the source upload as plain output 1`] = ` +"sources/alpha.md +sources/beta.md +sources/gamma.md" +`; + +exports[`full CLI project workflow uploads all source files to a fresh project 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'sources' +◆ File 'sources/alpha.md' +◆ File 'sources/beta.md' +◆ File 'sources/gamma.md'" +`; + +exports[`full CLI project workflow updates existing source files 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/alpha.md' +◆ File 'sources/beta.md' +◆ File 'sources/gamma.md'" +`; + +exports[`full CLI project workflow previews the translation upload 1`] = ` +"● Fetching project files +● Fetching project info +● File 'translations/it/alpha.md' would be queued for translations import +● File 'translations/it/beta.md' would be queued for translations import +● File 'translations/it/gamma.md' would be queued for translations import +● File 'translations/uk/alpha.md' would be queued for translations import +● File 'translations/uk/beta.md' would be queued for translations import +● File 'translations/uk/gamma.md' would be queued for translations import +● Project files fetched +● Project info fetched" +`; + +exports[`full CLI project workflow previews the translation upload as a tree 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +. +╰─ translations + ├─ it + │ ├─ alpha.md + │ ├─ beta.md + │ ╰─ gamma.md + ╰─ uk + ├─ alpha.md + ├─ beta.md + ╰─ gamma.md" +`; + +exports[`full CLI project workflow previews the translation upload as plain output 1`] = ` +"translations/it/alpha.md +translations/it/beta.md +translations/it/gamma.md +translations/uk/alpha.md +translations/uk/beta.md +translations/uk/gamma.md" +`; + +exports[`full CLI project workflow uploads translations for every target language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/alpha.md' +● Importing translations for file 'translations/it/beta.md' +● Importing translations for file 'translations/it/gamma.md' +● Importing translations for file 'translations/uk/alpha.md' +● Importing translations for file 'translations/uk/beta.md' +● Importing translations for file 'translations/uk/gamma.md' +● Project files fetched +● Project info fetched +◆ File 'translations/it/alpha.md' +◆ File 'translations/it/beta.md' +◆ File 'translations/it/gamma.md' +◆ File 'translations/uk/alpha.md' +◆ File 'translations/uk/beta.md' +◆ File 'translations/uk/gamma.md'" +`; + +exports[`full CLI project workflow uploads translations for a single language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/uk/alpha.md' +● Importing translations for file 'translations/uk/beta.md' +● Importing translations for file 'translations/uk/gamma.md' +● Project files fetched +● Project info fetched +◆ File 'translations/uk/alpha.md' +◆ File 'translations/uk/beta.md' +◆ File 'translations/uk/gamma.md'" +`; + +exports[`full CLI project workflow previews the translation download 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ translations/it/alpha.md +◆ translations/it/beta.md +◆ translations/it/gamma.md +◆ translations/uk/alpha.md +◆ translations/uk/beta.md +◆ translations/uk/gamma.md" +`; + +exports[`full CLI project workflow previews the translation download as a tree 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +. +╰─ translations + ├─ it + │ ├─ alpha.md + │ ├─ beta.md + │ ╰─ gamma.md + ╰─ uk + ├─ alpha.md + ├─ beta.md + ╰─ gamma.md" +`; + +exports[`full CLI project workflow previews the translation download as plain output 1`] = ` +"translations/it/alpha.md +translations/it/beta.md +translations/it/gamma.md +translations/uk/alpha.md +translations/uk/beta.md +translations/uk/gamma.md" +`; + +exports[`full CLI project workflow downloads translations for every target language 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/alpha.md' extracted +◆ File 'translations/it/beta.md' extracted +◆ File 'translations/it/gamma.md' extracted +◆ File 'translations/uk/alpha.md' extracted +◆ File 'translations/uk/beta.md' extracted +◆ File 'translations/uk/gamma.md' extracted" +`; + +exports[`full CLI project workflow downloads translations for a single language 1`] = ` +"● Building translations for languages: uk +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/uk/alpha.md' extracted +◆ File 'translations/uk/beta.md' extracted +◆ File 'translations/uk/gamma.md' extracted" +`; + +exports[`full CLI project workflow lists project source files as a tree 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +. +╰─ sources + ├─ alpha.md + ├─ beta.md + ╰─ gamma.md" +`; + +exports[`full CLI project workflow lists configured source files 1`] = ` +"● Fetching project info +● Project info fetched +◆ sources/alpha.md +◆ sources/beta.md +◆ sources/gamma.md" +`; + +exports[`full CLI project workflow lists configured source files as a tree 1`] = ` +"● Fetching project info +● Project info fetched +. +╰─ sources + ├─ alpha.md + ├─ beta.md + ╰─ gamma.md" +`; + +exports[`full CLI project workflow lists configured translation files 1`] = ` +"● Fetching project info +● Project info fetched +◆ translations/it/alpha.md +◆ translations/it/beta.md +◆ translations/it/gamma.md +◆ translations/uk/alpha.md +◆ translations/uk/beta.md +◆ translations/uk/gamma.md" +`; + +exports[`full CLI project workflow lists configured translation files as a tree 1`] = ` +"● Fetching project info +● Project info fetched +. +╰─ translations + ├─ it + │ ├─ alpha.md + │ ├─ beta.md + │ ╰─ gamma.md + ╰─ uk + ├─ alpha.md + ├─ beta.md + ╰─ gamma.md" +`; + +exports[`full CLI project workflow lists target languages 1`] = ` +"● Fetching project info +● Project info fetched +◆ it Italian +◆ uk Ukrainian" +`; + +exports[`full CLI project workflow downloads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/alpha.md' +◆ File 'sources/beta.md' +◆ File 'sources/gamma.md'" +`; + +exports[`full CLI project workflow validates a correct configuration file 1`] = `"◆ Your configuration file looks good"`; + diff --git a/tests/e2e/suites/__snapshots__/glossary.test.ts.snap b/tests/e2e/suites/__snapshots__/glossary.test.ts.snap new file mode 100644 index 000000000..35249e741 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/glossary.test.ts.snap @@ -0,0 +1,52 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`glossary uploads a TBX glossary, creating it 1`] = ` +"● Importing glossary +● Importing glossary () +◆ Imported in #id 'Created in Crowdin CLI (simple-glossary.tbx)' glossary +#id Created in Crowdin CLI (simple-glossary.tbx) (terms: 3)" +`; + +exports[`glossary uploads a CSV glossary with an explicit scheme, creating it 1`] = ` +"● Importing glossary +● Importing glossary () +◆ Imported in #id 'Created in Crowdin CLI (simple-glossary.csv)' glossary +#id Created in Crowdin CLI (simple-glossary.csv) (terms: 5)" +`; + +exports[`glossary uploads an XLSX glossary with an explicit scheme, creating it 1`] = ` +"● Importing glossary +● Importing glossary () +◆ Imported in #id 'Created in Crowdin CLI (simple-glossary.xlsx)' glossary +#id Created in Crowdin CLI (simple-glossary.xlsx) (terms: 5)" +`; + +exports[`glossary downloads the TBX glossary by id and format 1`] = ` +"● Building glossary +● Building glossary () +◆ 'Created in Crowdin CLI (simple-glossary.tbx).tbx' downloaded successfully" +`; + +exports[`glossary downloads the CSV glossary by id and format 1`] = ` +"● Building glossary +● Building glossary () +◆ 'Created in Crowdin CLI (simple-glossary.csv).csv' downloaded successfully" +`; + +exports[`glossary downloads the XLSX glossary by id and format 1`] = ` +"● Building glossary +● Building glossary () +◆ 'Created in Crowdin CLI (simple-glossary.xlsx).xlsx' downloaded successfully" +`; + +exports[`glossary downloads the TBX glossary without an explicit format 1`] = ` +"● Building glossary +● Building glossary () +◆ 'Created in Crowdin CLI (simple-glossary.tbx).tbx' downloaded successfully" +`; + +exports[`glossary downloads the project's default glossary by id 1`] = ` +"● Building glossary +● Building glossary () +◆ 'e2e--glossary's Glossary.tbx' downloaded successfully" +`; diff --git a/tests/e2e/suites/__snapshots__/identity.test.ts.snap b/tests/e2e/suites/__snapshots__/identity.test.ts.snap new file mode 100644 index 000000000..34f664f51 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/identity.test.ts.snap @@ -0,0 +1,34 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`identity file credentials uploads sources using credentials from an --identity file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`identity file credentials uploads translations using credentials from an --identity file 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/android.xml' +● Importing translations for file 'translations/uk/android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/it/android.xml' +◆ File 'translations/uk/android.xml'" +`; + +exports[`identity file credentials downloads translations using credentials from an --identity file 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/uk/android.xml' extracted" +`; + +exports[`identity file credentials validates the merged configuration via config lint --identity 1`] = `"◆ Your configuration file looks good"`; diff --git a/tests/e2e/suites/__snapshots__/ignore.test.ts.snap b/tests/e2e/suites/__snapshots__/ignore.test.ts.snap new file mode 100644 index 000000000..a01d354f5 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/ignore.test.ts.snap @@ -0,0 +1,37 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`ignore uploads hidden dotfiles when ignore_hidden_files is false 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'folder' +◆ Directory 'folder/sub' +◆ File 'folder/.hidden.xml' +◆ File 'folder/1.xml' +◆ File 'folder/123.xml' +◆ File 'folder/123_test.xml' +◆ File 'folder/a.xml' +◆ File 'folder/android-uk.xml' +◆ File 'folder/android.xml' +◆ File 'folder/sub/.hidden.xml' +◆ File 'folder/sub/1.txt' +◆ File 'folder/sub/1.xml'" +`; + +exports[`ignore skips hidden dotfiles when ignore_hidden_files is true 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'folder' +◆ Directory 'folder/sub' +◆ File 'folder/1.xml' +◆ File 'folder/123.xml' +◆ File 'folder/123_test.xml' +◆ File 'folder/a.xml' +◆ File 'folder/android-uk.xml' +◆ File 'folder/android.xml' +◆ File 'folder/sub/1.txt' +◆ File 'folder/sub/1.xml'" +`; diff --git a/tests/e2e/suites/__snapshots__/init.test.ts.snap b/tests/e2e/suites/__snapshots__/init.test.ts.snap new file mode 100644 index 000000000..b9986e183 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/init.test.ts.snap @@ -0,0 +1,20 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`init generates a configuration skeleton generates a configuration skeleton in quiet mode 1`] = ` +"┌ Generating Crowdin CLI configuration skeleton '/init/crowdin.yaml' +│ +└ Your configuration skeleton has been successfully generated. Specify your source and translation paths in the files section. For more details see https://crowdin.github.io/crowdin-cli/configuration +Next steps: run 'crowdin push' to upload sources and 'crowdin pull' to download translations." +`; + +exports[`init generates a configuration skeleton skips regeneration when the destination already exists 1`] = ` +"┌ Generating Crowdin CLI configuration skeleton '/init/crowdin.yaml' +│ +└ File '/init/crowdin.yaml' already exists. Fill it out accordingly to the following requirements: https://developer.crowdin.com/configuration-file/#configuration-file-structure" +`; + +exports[`init generates a configuration skeleton lints the generated (incomplete) skeleton 1`] = ` +"■ Configuration file is invalid. Configuration file is invalid. Check the following parameters in your configuration file: + - source parameter cannot be empty + - translation parameter cannot be empty" +`; diff --git a/tests/e2e/suites/__snapshots__/invalid-credentials.test.ts.snap b/tests/e2e/suites/__snapshots__/invalid-credentials.test.ts.snap new file mode 100644 index 000000000..d30e36955 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/invalid-credentials.test.ts.snap @@ -0,0 +1,11 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`invalid credentials rejects a non-numeric project_id 1`] = `""`; + +exports[`invalid credentials reports a project that does not exist 1`] = `"● Fetching project info"`; + +exports[`invalid credentials reports an invalid api_token 1`] = `"● Fetching project info"`; + +exports[`invalid credentials rejects a base_path that does not exist 1`] = `""`; + +exports[`invalid credentials rejects an invalid base_url 1`] = `""`; diff --git a/tests/e2e/suites/__snapshots__/invalid-files-config.test.ts.snap b/tests/e2e/suites/__snapshots__/invalid-files-config.test.ts.snap new file mode 100644 index 000000000..5b3800be3 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/invalid-files-config.test.ts.snap @@ -0,0 +1,26 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`invalid files config uploads sources with a source pattern that matches no local file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched" +`; + +exports[`invalid files config uploads sources with a source pattern whose folder does not exist 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched" +`; + +exports[`invalid files config uploads sources with a translation pattern missing a language placeholder 1`] = `""`; + +exports[`invalid files config uploads sources with a translation pattern containing a relative path 1`] = `""`; + +exports[`invalid files config uploads translations when the source file does not exist in the project 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched" +`; diff --git a/tests/e2e/suites/__snapshots__/label.test.ts.snap b/tests/e2e/suites/__snapshots__/label.test.ts.snap new file mode 100644 index 000000000..fa05470c5 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/label.test.ts.snap @@ -0,0 +1,14 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`label reports an empty project 1`] = `"● No labels found"`; + +exports[`label adds a label and echoes it back 1`] = `"◆ #id zebra-label"`; + +exports[`label lists both labels with their ids 1`] = ` +"◆ #id alpha-label +◆ #id zebra-label" +`; + +exports[`label rejects deleting a title the project does not have 1`] = `"■ Couldn't find label by the specified title"`; + +exports[`label deletes a label by title 1`] = `"◆ Label 'zebra-label' deleted successfully"`; diff --git a/tests/e2e/suites/__snapshots__/language-mapping.test.ts.snap b/tests/e2e/suites/__snapshots__/language-mapping.test.ts.snap new file mode 100644 index 000000000..9864d6e11 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/language-mapping.test.ts.snap @@ -0,0 +1,260 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`language mapping uploads sources for every language-mapping placeholder 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'android_code' +◆ Directory 'language' +◆ Directory 'locale' +◆ Directory 'locale_with_underscore' +◆ Directory 'osx_code' +◆ Directory 'osx_locale' +◆ Directory 'three_letters_code' +◆ Directory 'two_letters_code' +◆ File 'android_code/android.xml' +◆ File 'language/android.xml' +◆ File 'locale/android.xml' +◆ File 'locale_with_underscore/android.xml' +◆ File 'osx_code/android.xml' +◆ File 'osx_locale/android.xml' +◆ File 'three_letters_code/android.xml' +◆ File 'two_letters_code/android.xml'" +`; + +exports[`language mapping previews the translation upload with the default language mapping 1`] = ` +"● Fetching project files +● Fetching project info +● File 'android_code/uk-rUA_/android.xml' would be queued for translations import +● File 'android_code/zh-rCN_/android.xml' would be queued for translations import +● File 'language/Chinese Simplified_/android.xml' would be queued for translations import +● File 'language/Ukrainian_/android.xml' would be queued for translations import +● File 'locale/uk-UA_/android.xml' would be queued for translations import +● File 'locale/zh-CN_/android.xml' would be queued for translations import +● File 'locale_with_underscore/uk_UA_/android.xml' would be queued for translations import +● File 'locale_with_underscore/zh_CN_/android.xml' would be queued for translations import +● File 'osx_code/uk.lproj_/android.xml' would be queued for translations import +● File 'osx_code/zh-Hans.lproj_/android.xml' would be queued for translations import +● File 'osx_locale/uk_/android.xml' would be queued for translations import +● File 'osx_locale/zh-Hans_/android.xml' would be queued for translations import +● File 'three_letters_code/ukr_/android.xml' would be queued for translations import +● File 'three_letters_code/zho_/android.xml' would be queued for translations import +● File 'two_letters_code/uk_/android.xml' would be queued for translations import +● File 'two_letters_code/zh_/android.xml' would be queued for translations import +● Project files fetched +● Project info fetched" +`; + +exports[`language mapping uploads translations with the default language mapping 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'android_code/uk-rUA_/android.xml' +● Importing translations for file 'android_code/zh-rCN_/android.xml' +● Importing translations for file 'language/Chinese Simplified_/android.xml' +● Importing translations for file 'language/Ukrainian_/android.xml' +● Importing translations for file 'locale/uk-UA_/android.xml' +● Importing translations for file 'locale/zh-CN_/android.xml' +● Importing translations for file 'locale_with_underscore/uk_UA_/android.xml' +● Importing translations for file 'locale_with_underscore/zh_CN_/android.xml' +● Importing translations for file 'osx_code/uk.lproj_/android.xml' +● Importing translations for file 'osx_code/zh-Hans.lproj_/android.xml' +● Importing translations for file 'osx_locale/uk_/android.xml' +● Importing translations for file 'osx_locale/zh-Hans_/android.xml' +● Importing translations for file 'three_letters_code/ukr_/android.xml' +● Importing translations for file 'three_letters_code/zho_/android.xml' +● Importing translations for file 'two_letters_code/uk_/android.xml' +● Importing translations for file 'two_letters_code/zh_/android.xml' +● Project files fetched +● Project info fetched +◆ File 'android_code/uk-rUA_/android.xml' +◆ File 'android_code/zh-rCN_/android.xml' +◆ File 'language/Chinese Simplified_/android.xml' +◆ File 'language/Ukrainian_/android.xml' +◆ File 'locale/uk-UA_/android.xml' +◆ File 'locale/zh-CN_/android.xml' +◆ File 'locale_with_underscore/uk_UA_/android.xml' +◆ File 'locale_with_underscore/zh_CN_/android.xml' +◆ File 'osx_code/uk.lproj_/android.xml' +◆ File 'osx_code/zh-Hans.lproj_/android.xml' +◆ File 'osx_locale/uk_/android.xml' +◆ File 'osx_locale/zh-Hans_/android.xml' +◆ File 'three_letters_code/ukr_/android.xml' +◆ File 'three_letters_code/zho_/android.xml' +◆ File 'two_letters_code/uk_/android.xml' +◆ File 'two_letters_code/zh_/android.xml'" +`; + +exports[`language mapping previews the translation download with the default language mapping 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ android_code/uk-rUA_/android.xml +◆ android_code/zh-rCN_/android.xml +◆ language/Chinese Simplified_/android.xml +◆ language/Ukrainian_/android.xml +◆ locale/uk-UA_/android.xml +◆ locale/zh-CN_/android.xml +◆ locale_with_underscore/uk_UA_/android.xml +◆ locale_with_underscore/zh_CN_/android.xml +◆ osx_code/uk.lproj_/android.xml +◆ osx_code/zh-Hans.lproj_/android.xml +◆ osx_locale/uk_/android.xml +◆ osx_locale/zh-Hans_/android.xml +◆ three_letters_code/ukr_/android.xml +◆ three_letters_code/zho_/android.xml +◆ two_letters_code/uk_/android.xml +◆ two_letters_code/zh_/android.xml" +`; + +exports[`language mapping downloads translations with the default language mapping 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'android_code/uk-rUA_/android.xml' extracted +◆ File 'android_code/zh-rCN_/android.xml' extracted +◆ File 'language/Chinese Simplified_/android.xml' extracted +◆ File 'language/Ukrainian_/android.xml' extracted +◆ File 'locale/uk-UA_/android.xml' extracted +◆ File 'locale/zh-CN_/android.xml' extracted +◆ File 'locale_with_underscore/uk_UA_/android.xml' extracted +◆ File 'locale_with_underscore/zh_CN_/android.xml' extracted +◆ File 'osx_code/uk.lproj_/android.xml' extracted +◆ File 'osx_code/zh-Hans.lproj_/android.xml' extracted +◆ File 'osx_locale/uk_/android.xml' extracted +◆ File 'osx_locale/zh-Hans_/android.xml' extracted +◆ File 'three_letters_code/ukr_/android.xml' extracted +◆ File 'three_letters_code/zho_/android.xml' extracted +◆ File 'two_letters_code/uk_/android.xml' extracted +◆ File 'two_letters_code/zh_/android.xml' extracted" +`; + +exports[`language mapping sets a server-side language mapping and previews the upload without local overrides 1`] = ` +"● Fetching project files +● Fetching project info +● File 'android_code/uk-rUA_crwd/android.xml' would be queued for translations import +● File 'android_code/zh-rCN_crwd/android.xml' would be queued for translations import +● File 'language/Chinese Simplified_/android.xml' would be queued for translations import +● File 'language/Ukrainian_/android.xml' would be queued for translations import +● File 'locale/uk-UA_/android.xml' would be queued for translations import +● File 'locale/zh-CN_/android.xml' would be queued for translations import +● File 'locale_with_underscore/uk_UA_/android.xml' would be queued for translations import +● File 'locale_with_underscore/zh_CN_/android.xml' would be queued for translations import +● File 'osx_code/uk.lproj_/android.xml' would be queued for translations import +● File 'osx_code/zh-Hans.lproj_/android.xml' would be queued for translations import +● File 'osx_locale/uk_/android.xml' would be queued for translations import +● File 'osx_locale/zh-Hans_/android.xml' would be queued for translations import +● File 'three_letters_code/ukr_/android.xml' would be queued for translations import +● File 'three_letters_code/zho_/android.xml' would be queued for translations import +● File 'two_letters_code/uk_/android.xml' would be queued for translations import +● File 'two_letters_code/zh_/android.xml' would be queued for translations import +● Project files fetched +● Project info fetched" +`; + +exports[`language mapping lists target languages using the android_code mapping 1`] = ` +"● Fetching project info +● Project info fetched +◆ uk-rUA_crwd Ukrainian +◆ zh-rCN_crwd Chinese Simplified" +`; + +exports[`language mapping lists target languages using the three_letters_code mapping 1`] = ` +"● Fetching project info +● Project info fetched +◆ ukr_ Ukrainian +◆ zho_ Chinese Simplified" +`; + +exports[`language mapping uploads translations under the server-side language mapping 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'android_code/uk-rUA_crwd/android.xml' +● Importing translations for file 'android_code/zh-rCN_crwd/android.xml' +● Importing translations for file 'language/Chinese Simplified_/android.xml' +● Importing translations for file 'language/Ukrainian_/android.xml' +● Importing translations for file 'locale/uk-UA_/android.xml' +● Importing translations for file 'locale/zh-CN_/android.xml' +● Importing translations for file 'locale_with_underscore/uk_UA_/android.xml' +● Importing translations for file 'locale_with_underscore/zh_CN_/android.xml' +● Importing translations for file 'osx_code/uk.lproj_/android.xml' +● Importing translations for file 'osx_code/zh-Hans.lproj_/android.xml' +● Importing translations for file 'osx_locale/uk_/android.xml' +● Importing translations for file 'osx_locale/zh-Hans_/android.xml' +● Importing translations for file 'three_letters_code/ukr_/android.xml' +● Importing translations for file 'three_letters_code/zho_/android.xml' +● Importing translations for file 'two_letters_code/uk_/android.xml' +● Importing translations for file 'two_letters_code/zh_/android.xml' +● Project files fetched +● Project info fetched +◆ File 'android_code/uk-rUA_crwd/android.xml' +◆ File 'android_code/zh-rCN_crwd/android.xml' +◆ File 'language/Chinese Simplified_/android.xml' +◆ File 'language/Ukrainian_/android.xml' +◆ File 'locale/uk-UA_/android.xml' +◆ File 'locale/zh-CN_/android.xml' +◆ File 'locale_with_underscore/uk_UA_/android.xml' +◆ File 'locale_with_underscore/zh_CN_/android.xml' +◆ File 'osx_code/uk.lproj_/android.xml' +◆ File 'osx_code/zh-Hans.lproj_/android.xml' +◆ File 'osx_locale/uk_/android.xml' +◆ File 'osx_locale/zh-Hans_/android.xml' +◆ File 'three_letters_code/ukr_/android.xml' +◆ File 'three_letters_code/zho_/android.xml' +◆ File 'two_letters_code/uk_/android.xml' +◆ File 'two_letters_code/zh_/android.xml'" +`; + +exports[`language mapping previews the translation download under the server-side language mapping 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ android_code/uk-rUA_crwd/android.xml +◆ android_code/zh-rCN_crwd/android.xml +◆ language/Chinese Simplified_/android.xml +◆ language/Ukrainian_/android.xml +◆ locale/uk-UA_/android.xml +◆ locale/zh-CN_/android.xml +◆ locale_with_underscore/uk_UA_/android.xml +◆ locale_with_underscore/zh_CN_/android.xml +◆ osx_code/uk.lproj_/android.xml +◆ osx_code/zh-Hans.lproj_/android.xml +◆ osx_locale/uk_/android.xml +◆ osx_locale/zh-Hans_/android.xml +◆ three_letters_code/ukr_/android.xml +◆ three_letters_code/zho_/android.xml +◆ two_letters_code/uk_/android.xml +◆ two_letters_code/zh_/android.xml" +`; + +exports[`language mapping downloads translations under the server-side language mapping 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'android_code/uk-rUA_crwd/android.xml' extracted +◆ File 'android_code/zh-rCN_crwd/android.xml' extracted +◆ File 'language/Chinese Simplified_/android.xml' extracted +◆ File 'language/Ukrainian_/android.xml' extracted +◆ File 'locale/uk-UA_/android.xml' extracted +◆ File 'locale/zh-CN_/android.xml' extracted +◆ File 'locale_with_underscore/uk_UA_/android.xml' extracted +◆ File 'locale_with_underscore/zh_CN_/android.xml' extracted +◆ File 'osx_code/uk.lproj_/android.xml' extracted +◆ File 'osx_code/zh-Hans.lproj_/android.xml' extracted +◆ File 'osx_locale/uk_/android.xml' extracted +◆ File 'osx_locale/zh-Hans_/android.xml' extracted +◆ File 'three_letters_code/ukr_/android.xml' extracted +◆ File 'three_letters_code/zho_/android.xml' extracted +◆ File 'two_letters_code/uk_/android.xml' extracted +◆ File 'two_letters_code/zh_/android.xml' extracted" +`; diff --git a/tests/e2e/suites/__snapshots__/language.test.ts.snap b/tests/e2e/suites/__snapshots__/language.test.ts.snap new file mode 100644 index 000000000..f0cf0ea16 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/language.test.ts.snap @@ -0,0 +1,8 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`language lists the target languages of the project 1`] = ` +"● Fetching project info +● Project info fetched +◆ it Italian +◆ uk Ukrainian" +`; diff --git a/tests/e2e/suites/__snapshots__/multilingual-csv-with-language-placeholder.test.ts.snap b/tests/e2e/suites/__snapshots__/multilingual-csv-with-language-placeholder.test.ts.snap new file mode 100644 index 000000000..5423e4752 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/multilingual-csv-with-language-placeholder.test.ts.snap @@ -0,0 +1,134 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`multilingual csv with language placeholder uploads multilingual CSV sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'sources' +◆ File 'sources/1_multilingual.csv' +◆ File 'sources/2_multilingual.csv'" +`; + +exports[`multilingual csv with language placeholder uploads translations for every target language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/1_multilingual.csv' +● Importing translations for file 'translations/it/2_multilingual.csv' +● Importing translations for file 'translations/uk/1_multilingual.csv' +● Importing translations for file 'translations/uk/2_multilingual.csv' +● Project files fetched +● Project info fetched +◆ File 'translations/it/1_multilingual.csv' +◆ File 'translations/it/2_multilingual.csv' +◆ File 'translations/uk/1_multilingual.csv' +◆ File 'translations/uk/2_multilingual.csv'" +`; + +exports[`multilingual csv with language placeholder uploads translations for a single language via --language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/uk/1_multilingual.csv' +● Importing translations for file 'translations/uk/2_multilingual.csv' +● Project files fetched +● Project info fetched +◆ File 'translations/uk/1_multilingual.csv' +◆ File 'translations/uk/2_multilingual.csv'" +`; + +exports[`multilingual csv with language placeholder previews the translation download (dryrun) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ translations/it/1_multilingual.csv +◆ translations/it/2_multilingual.csv +◆ translations/uk/1_multilingual.csv +◆ translations/uk/2_multilingual.csv" +`; + +exports[`multilingual csv with language placeholder downloads translations and matches the merged multilingual content 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_multilingual.csv' extracted +◆ File 'translations/it/2_multilingual.csv' extracted +◆ File 'translations/uk/1_multilingual.csv' extracted +◆ File 'translations/uk/2_multilingual.csv' extracted" +`; + +exports[`multilingual csv with language placeholder updates sources from a new base path, targeting a new translation destination 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/1_multilingual.csv' +◆ File 'sources/2_multilingual.csv'" +`; + +exports[`multilingual csv with language placeholder downloads translations at the new translations-v2 destination 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations-v2/it/1_multilingual.csv' extracted +◆ File 'translations-v2/it/2_multilingual.csv' extracted +◆ File 'translations-v2/uk/1_multilingual.csv' extracted +◆ File 'translations-v2/uk/2_multilingual.csv' extracted" +`; + +exports[`multilingual csv with language placeholder uploads sources to a new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch' +◆ Directory 'sources' +◆ File 'sources/1_multilingual.csv' +◆ File 'sources/2_multilingual.csv'" +`; + +exports[`multilingual csv with language placeholder updates sources on the branch (branch already exists) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/1_multilingual.csv' +◆ File 'sources/2_multilingual.csv'" +`; + +exports[`multilingual csv with language placeholder uploads translations on the branch 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/1_multilingual.csv' +● Importing translations for file 'translations/it/2_multilingual.csv' +● Importing translations for file 'translations/uk/1_multilingual.csv' +● Importing translations for file 'translations/uk/2_multilingual.csv' +● Project files fetched +● Project info fetched +◆ File 'translations/it/1_multilingual.csv' +◆ File 'translations/it/2_multilingual.csv' +◆ File 'translations/uk/1_multilingual.csv' +◆ File 'translations/uk/2_multilingual.csv'" +`; + +exports[`multilingual csv with language placeholder downloads translations on the branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_multilingual.csv' extracted +◆ File 'translations/it/2_multilingual.csv' extracted +◆ File 'translations/uk/1_multilingual.csv' extracted +◆ File 'translations/uk/2_multilingual.csv' extracted" +`; diff --git a/tests/e2e/suites/__snapshots__/multilingual-csv.test.ts.snap b/tests/e2e/suites/__snapshots__/multilingual-csv.test.ts.snap new file mode 100644 index 000000000..19a71a67a --- /dev/null +++ b/tests/e2e/suites/__snapshots__/multilingual-csv.test.ts.snap @@ -0,0 +1,117 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`multilingual csv upload with translations import 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'with-translations' +◆ File 'with-translations/sample.csv'" +`; + +exports[`multilingual csv update with translations import 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'with-translations/sample.csv'" +`; + +exports[`multilingual csv download file with translations (dryrun) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ with-translations/sample.csv" +`; + +exports[`multilingual csv download file with translations 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'with-translations/sample.csv' extracted" +`; + +exports[`multilingual csv upload without translations import 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'without-translations' +◆ File 'without-translations/sample.csv'" +`; + +exports[`multilingual csv update without translations import 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'without-translations/sample.csv'" +`; + +exports[`multilingual csv upload translations for single language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'without-translations/sample.csv' +● Project files fetched +● Project info fetched +◆ File 'without-translations/sample.csv'" +`; + +exports[`multilingual csv upload translations for all languages 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'without-translations/sample.csv' +● Project files fetched +● Project info fetched +◆ File 'without-translations/sample.csv'" +`; + +exports[`multilingual csv upload sources to a brand-new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch' +◆ File 'sample.csv'" +`; + +exports[`multilingual csv update sources in existing branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sample.csv'" +`; + +exports[`multilingual csv upload translations to the branch 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'sample.csv' +● Project files fetched +● Project info fetched +◆ File 'sample.csv'" +`; + +exports[`multilingual csv download translations from branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'sample.csv' extracted" +`; + +exports[`multilingual csv upload source to the root of the project 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sample.csv'" +`; diff --git a/tests/e2e/suites/__snapshots__/screenshot.test.ts.snap b/tests/e2e/suites/__snapshots__/screenshot.test.ts.snap new file mode 100644 index 000000000..e354d66a7 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/screenshot.test.ts.snap @@ -0,0 +1,12 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`screenshot reports a project with no screenshots 1`] = `"● No screenshot found"`; + +exports[`screenshot rejects a file that is not an allowed image format 1`] = `"■ Wrong format of the file. Supported formats: jpeg, jpg, png, gif"`; + +exports[`screenshot uploads a screenshot and attaches a label 1`] = `"◆ #id 0 screenshot.png"`; + +exports[`screenshot lists both screenshots with id and tag count 1`] = ` +"◆ #id 0 screenshot.png +◆ #id 0 second.png" +`; diff --git a/tests/e2e/suites/__snapshots__/simple-csv.test.ts.snap b/tests/e2e/suites/__snapshots__/simple-csv.test.ts.snap new file mode 100644 index 000000000..eb8452bf8 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/simple-csv.test.ts.snap @@ -0,0 +1,145 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`simple csv uploads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'sources' +◆ Directory 'sources/files' +◆ File 'sources/files/1_simple.csv' +◆ File 'sources/files/2_simple.csv'" +`; + +exports[`simple csv updates sources after local changes 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/files/1_simple.csv' +◆ File 'sources/files/2_simple.csv'" +`; + +exports[`simple csv uploads translations for every target language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'sources/files/it/1_simple.csv' +● Importing translations for file 'sources/files/it/2_simple.csv' +● Importing translations for file 'sources/files/uk/1_simple.csv' +● Importing translations for file 'sources/files/uk/2_simple.csv' +● Project files fetched +● Project info fetched +◆ File 'sources/files/it/1_simple.csv' +◆ File 'sources/files/it/2_simple.csv' +◆ File 'sources/files/uk/1_simple.csv' +◆ File 'sources/files/uk/2_simple.csv'" +`; + +exports[`simple csv uploads translations for a single language 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'sources/files/uk/1_simple.csv' +● Importing translations for file 'sources/files/uk/2_simple.csv' +● Project files fetched +● Project info fetched +◆ File 'sources/files/uk/1_simple.csv' +◆ File 'sources/files/uk/2_simple.csv'" +`; + +exports[`simple csv downloads translations for a single language 1`] = ` +"● Building translations for languages: uk +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'sources/files/uk/1_simple.csv' extracted +◆ File 'sources/files/uk/2_simple.csv' extracted" +`; + +exports[`simple csv downloads translations for every target language 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'sources/files/it/1_simple.csv' extracted +◆ File 'sources/files/it/2_simple.csv' extracted +◆ File 'sources/files/uk/1_simple.csv' extracted +◆ File 'sources/files/uk/2_simple.csv' extracted" +`; + +exports[`simple csv uploads sources to a brand-new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch' +◆ Directory 'sources' +◆ Directory 'sources/files' +◆ File 'sources/files/1_simple.csv' +◆ File 'sources/files/2_simple.csv'" +`; + +exports[`simple csv updates sources on the branch (branch already exists) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/files/1_simple.csv' +◆ File 'sources/files/2_simple.csv'" +`; + +exports[`simple csv uploads translations to the branch 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'sources/files/it/1_simple.csv' +● Importing translations for file 'sources/files/it/2_simple.csv' +● Importing translations for file 'sources/files/uk/1_simple.csv' +● Importing translations for file 'sources/files/uk/2_simple.csv' +● Project files fetched +● Project info fetched +◆ File 'sources/files/it/1_simple.csv' +◆ File 'sources/files/it/2_simple.csv' +◆ File 'sources/files/uk/1_simple.csv' +◆ File 'sources/files/uk/2_simple.csv'" +`; + +exports[`simple csv downloads translations for a single language on the branch 1`] = ` +"● Building translations for languages: uk +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'sources/files/uk/1_simple.csv' extracted +◆ File 'sources/files/uk/2_simple.csv' extracted" +`; + +exports[`simple csv downloads translations for every target language on the branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'sources/files/it/1_simple.csv' extracted +◆ File 'sources/files/it/2_simple.csv' extracted +◆ File 'sources/files/uk/1_simple.csv' extracted +◆ File 'sources/files/uk/2_simple.csv' extracted" +`; + +exports[`simple csv rejects a scheme missing the Source String/Translation elements, on a new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch-invalid-scheme' +◆ Directory 'sources' +◆ Directory 'sources/files'" +`; diff --git a/tests/e2e/suites/__snapshots__/status.test.ts.snap b/tests/e2e/suites/__snapshots__/status.test.ts.snap new file mode 100644 index 000000000..e44d1f6c3 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/status.test.ts.snap @@ -0,0 +1,71 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`status renders both languages as a table 1`] = ` +"● Fetching project info +● Fetching project progress +● Project info fetched +● Project progress fetched +┌───────────────┬────────────┬───────────┐ +│ │ Translated │ Proofread │ +├───────────────┼────────────┼───────────┤ +│ Italian(it) │ 0% │ 0% │ +│ Ukrainian(uk) │ 100% │ 0% │ +└───────────────┴────────────┴───────────┘" +`; + +exports[`status adds word and phrase columns with --verbose 1`] = ` +"● Fetching project info +● Fetching project progress +● Loading configuration from '/status/crowdin.yml' file +● Project info fetched +● Project progress fetched +┌───────────────┬────────────┬──────────────────┬────────────────────┬───────────┬─────────────────┬───────────────────┐ +│ │ Translated │ Translated words │ Translated phrases │ Proofread │ Proofread words │ Proofread phrases │ +├───────────────┼────────────┼──────────────────┼────────────────────┼───────────┼─────────────────┼───────────────────┤ +│ Italian(it) │ 0% │ 0/32 │ 0/8 │ 0% │ 0/32 │ 0/8 │ +│ Ukrainian(uk) │ 100% │ 32/32 │ 8/8 │ 0% │ 0/32 │ 0/8 │ +└───────────────┴────────────┴──────────────────┴────────────────────┴───────────┴─────────────────┴───────────────────┘" +`; + +exports[`status shows only the translation column for \`status translation\` 1`] = ` +"● Fetching project info +● Fetching project progress +● Project info fetched +● Project progress fetched +┌───────────────┬────────────┐ +│ │ Translated │ +├───────────────┼────────────┤ +│ Italian(it) │ 0% │ +│ Ukrainian(uk) │ 100% │ +└───────────────┴────────────┘" +`; + +exports[`status shows only the proofreading column for \`status proofreading\` 1`] = ` +"● Fetching project info +● Fetching project progress +● Project info fetched +● Project progress fetched +┌───────────────┬───────────┐ +│ │ Proofread │ +├───────────────┼───────────┤ +│ Italian(it) │ 0% │ +│ Ukrainian(uk) │ 0% │ +└───────────────┴───────────┘" +`; + +exports[`status rejects a language the project does not target 1`] = `"■ Language 'zz' doesn't exist in the project. Try specifying another language code"`; + +exports[`status rejects --file and --directory together 1`] = `"■ Only one of the following options can be used at a time: '--file', '--directory'"`; + +exports[`status fails on an incomplete project with --fail-if-incomplete 1`] = ` +"● Fetching project info +● Fetching project progress +● Project info fetched +● Project progress fetched +┌───────────────┬────────────┬───────────┐ +│ │ Translated │ Proofread │ +├───────────────┼────────────┼───────────┤ +│ Italian(it) │ 0% │ 0% │ +│ Ukrainian(uk) │ 100% │ 0% │ +└───────────────┴────────────┴───────────┘" +`; diff --git a/tests/e2e/suites/__snapshots__/string.test.ts.snap b/tests/e2e/suites/__snapshots__/string.test.ts.snap new file mode 100644 index 000000000..e4e5e53a4 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/string.test.ts.snap @@ -0,0 +1,153 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`string uploads sources 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml' +◆ File 'text.txt'" +`; + +exports[`string lists all source strings 1`] = ` +"◆ #id First text string. +◆ #id Second text string. +◆ #id str1 first string +◆ #id str2 second string +◆ #id str_with_quote first string source' with quotes +◆ #id str_with_tag first string source\` with tag" +`; + +exports[`string lists source strings authenticating via -T/-i against a config without an api_token 1`] = ` +"◆ #id First text string. +◆ #id Second text string. +◆ #id str1 first string +◆ #id str2 second string +◆ #id str_with_quote first string source' with quotes +◆ #id str_with_tag first string source\` with tag" +`; + +exports[`string lists source strings filtered by file 1`] = ` +"◆ #id str1 first string +◆ #id str2 second string +◆ #id str_with_quote first string source' with quotes +◆ #id str_with_tag first string source\` with tag" +`; + +exports[`string lists source strings filtered by identifier/text/context 1`] = `"◆ #id str1 first string"`; + +exports[`string lists source strings verbosely, including file and context 1`] = ` +"● Loading configuration from '/string/crowdin.yml' file +◆ #id First text string. +◆ #id Second text string. +◆ #id str1 first string +◆ #id str2 second string +◆ #id str_with_quote first string source' with quotes +◆ #id str_with_tag first string source\` with tag + - context: str1 + - context: str2 + - context: str_with_quote + - context: str_with_tag + - file: /android.xml + - file: /android.xml + - file: /android.xml + - file: /android.xml + - file: /text.txt + - file: /text.txt" +`; + +exports[`string adds a new source string 1`] = `"◆ #id str3 third string"`; + +exports[`string adds a new source string with all parameters 1`] = `"◆ #id str4 fourth string"`; + +exports[`string edits a source string 1`] = ` +"◆ #id str3 third string edited +◆ Source string #id was updated successfully" +`; + +exports[`string edits a source string with all parameters 1`] = ` +"◆ #id str4 fourth string edited +◆ Source string #id was updated successfully" +`; + +exports[`string deletes a source string 1`] = `"◆ Source string #id was deleted successfully"`; + +exports[`string uploads sources to a new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch' +◆ File 'android.xml' +◆ File 'text.txt'" +`; + +exports[`string adds a source string to a branch-scoped file 1`] = `"◆ #id str3 third string"`; + +exports[`string deletes a source string from a branch-scoped file 1`] = `"◆ Source string #id was deleted successfully"`; + +exports[`string reports a missing file when listing by file 1`] = `""`; + +exports[`string warns then fails adding a string to a missing file 1`] = `""`; + +exports[`string fails adding a string to an unsupported file type 1`] = `""`; + +exports[`string requires non-empty text when adding a string 1`] = `""`; + +exports[`string requires an identifier when adding a string without one 1`] = `""`; + +exports[`string reports a missing string when editing a nonexistent id 1`] = `""`; + +exports[`string fails editing a string in an unsupported file type 1`] = `""`; + +exports[`string reports a missing string when deleting a nonexistent id 1`] = `""`; + +exports[`string fails deleting a string in an unsupported file type 1`] = `""`; + +exports[`string lists source strings by a CroQL expression 1`] = ` +"◆ #id First text string. +◆ #id First text string. +◆ #id Second text string. +◆ #id Second text string. +◆ #id str1 first string +◆ #id str2 second string +◆ #id str2 second string +◆ #id str3 third string +◆ #id str4 fourth string edited +◆ #id str_with_quote first string source' with quotes +◆ #id str_with_quote first string source' with quotes +◆ #id str_with_tag first string source\` with tag +◆ #id str_with_tag first string source\` with tag" +`; + +exports[`string lists source strings by a CroQL text match, spanning both branches 1`] = ` +"◆ #id str_with_tag first string source\` with tag +◆ #id str_with_tag first string source\` with tag" +`; + +exports[`string lists source strings by a CroQL text match with a quote, spanning both branches 1`] = ` +"◆ #id str_with_quote first string source' with quotes +◆ #id str_with_quote first string source' with quotes" +`; + +exports[`string rejects an invalid CroQL expression 1`] = `""`; + +exports[`string adds a comment to a source string 1`] = `"◆ #id Added comment"`; + +exports[`string lists comments 1`] = `"◆ #id Added comment"`; + +exports[`string adds an issue to a source string 1`] = `"◆ #id Added comment string id 10"`; + +exports[`string adds an issue with a context-request type 1`] = `"◆ #id Added issue string context_request id 10"`; + +exports[`string lists comments filtered by a specific string id 1`] = ` +"◆ #id Added comment string id 10 +◆ #id Added issue string context_request id 10" +`; + +exports[`string resolves a string issue 1`] = ` +"◆ A string issue #id has been successfully resolved +#id Added issue string context_request id 10" +`; + +exports[`string lists comments filtered by unresolved status 1`] = `"◆ #id Added comment string id 10"`; diff --git a/tests/e2e/suites/__snapshots__/task.test.ts.snap b/tests/e2e/suites/__snapshots__/task.test.ts.snap new file mode 100644 index 000000000..ffa04ecbe --- /dev/null +++ b/tests/e2e/suites/__snapshots__/task.test.ts.snap @@ -0,0 +1,20 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`task reports a project with no tasks 1`] = `"● No tasks found"`; + +exports[`task rejects --include-pre-translated-strings-only on a 'translate' task 1`] = `"■ The '--include-pre-translated-strings-only' option can't be used with the 'translate' task type"`; + +exports[`task adds a translate task 1`] = `"◆ #id uk Translate file one"`; + +exports[`task lists every task with its id and target language 1`] = ` +"◆ #id it Proofread file two +◆ #id uk Labelled file two +◆ #id uk Translate file one" +`; + +exports[`task adds status, word count and due date with --verbose 1`] = ` +"● Loading configuration from '/task/crowdin.yml' file +◆ #id it Proofread file two todo 8 NoDueDate +◆ #id uk Labelled file two todo 8 NoDueDate +◆ #id uk Translate file one todo 12 NoDueDate" +`; diff --git a/tests/e2e/suites/__snapshots__/tm.test.ts.snap b/tests/e2e/suites/__snapshots__/tm.test.ts.snap new file mode 100644 index 000000000..f1884702a --- /dev/null +++ b/tests/e2e/suites/__snapshots__/tm.test.ts.snap @@ -0,0 +1,58 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`tm uploads a TMX translation memory, creating it 1`] = ` +"● Importing translation memory +● Importing translation memory () +◆ Imported in #id 'Created in Crowdin CLI (simple-tm.tmx)' translation memory +#id Created in Crowdin CLI (simple-tm.tmx) (segments: 4)" +`; + +exports[`tm uploads a CSV translation memory with an explicit scheme, creating it 1`] = ` +"● Importing translation memory +● Importing translation memory () +◆ Imported in #id 'Created in Crowdin CLI (simple-tm.csv)' translation memory +#id Created in Crowdin CLI (simple-tm.csv) (segments: 4)" +`; + +exports[`tm uploads an XLSX translation memory with an explicit scheme, creating it 1`] = ` +"● Importing translation memory +● Importing translation memory () +◆ Imported in #id 'Created in Crowdin CLI (simple-tm.xlsx)' translation memory +#id Created in Crowdin CLI (simple-tm.xlsx) (segments: 4)" +`; + +exports[`tm downloads the TMX translation memory by id and format 1`] = ` +"● Building translation memory +● Building translation memory () +◆ 'Created in Crowdin CLI (simple-tm.tmx).tmx' downloaded successfully" +`; + +exports[`tm downloads the CSV translation memory by id and format 1`] = ` +"● Building translation memory +● Building translation memory () +◆ 'Created in Crowdin CLI (simple-tm.csv).csv' downloaded successfully" +`; + +exports[`tm downloads the XLSX translation memory by id and format 1`] = ` +"● Building translation memory +● Building translation memory () +◆ 'Created in Crowdin CLI (simple-tm.xlsx).xlsx' downloaded successfully" +`; + +exports[`tm downloads the TMX translation memory filtered by a language pair 1`] = ` +"● Building translation memory +● Building translation memory () +◆ 'download/simple-tm_en-uk.tmx' downloaded successfully" +`; + +exports[`tm downloads the TMX translation memory without an explicit format 1`] = ` +"● Building translation memory +● Building translation memory () +◆ 'Created in Crowdin CLI (simple-tm.tmx).tmx' downloaded successfully" +`; + +exports[`tm downloads the project's default translation memory by id 1`] = ` +"● Building translation memory +● Building translation memory () +◆ 'e2e--tm's TM.tmx' downloaded successfully" +`; diff --git a/tests/e2e/suites/__snapshots__/translation-patterns.test.ts.snap b/tests/e2e/suites/__snapshots__/translation-patterns.test.ts.snap new file mode 100644 index 000000000..ba684028e --- /dev/null +++ b/tests/e2e/suites/__snapshots__/translation-patterns.test.ts.snap @@ -0,0 +1,186 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`translation patterns uploads sources across every placeholder-pattern file group 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'android_code' +◆ Directory 'doubled_asterisk' +◆ Directory 'doubled_asterisk/res' +◆ Directory 'doubled_asterisk/res/values' +◆ Directory 'language' +◆ Directory 'locale' +◆ Directory 'locale_with_underscore' +◆ Directory 'osx_code' +◆ Directory 'osx_locale' +◆ Directory 'three_letters_code' +◆ Directory 'two_letters_code' +◆ Directory 'two_letters_code_with_original_path' +◆ File 'android_code/android.xml' +◆ File 'doubled_asterisk/res/values/android.xml' +◆ File 'language/android.xml' +◆ File 'locale/android.xml' +◆ File 'locale_with_underscore/android.xml' +◆ File 'osx_code/android.xml' +◆ File 'osx_locale/android.xml' +◆ File 'three_letters_code/android.xml' +◆ File 'two_letters_code/android.xml' +◆ File 'two_letters_code_with_original_path/android.xml'" +`; + +exports[`translation patterns previews the translation upload across every placeholder-pattern file group 1`] = ` +"● Fetching project files +● Fetching project info +● File 'android_code/uk-rUA/android.xml' would be queued for translations import +● File 'android_code/zh-rCN/android.xml' would be queued for translations import +● File 'doubled_asterisk/res/values-uk/android.xml' would be queued for translations import +● File 'doubled_asterisk/res/values-zh/android.xml' would be queued for translations import +● File 'language/Chinese Simplified/android.xml' would be queued for translations import +● File 'language/Ukrainian/android.xml' would be queued for translations import +● File 'locale/uk-UA/android.xml' would be queued for translations import +● File 'locale/zh-CN/android.xml' would be queued for translations import +● File 'locale_with_underscore/uk_UA/android.xml' would be queued for translations import +● File 'locale_with_underscore/zh_CN/android.xml' would be queued for translations import +● File 'osx_code/uk.lproj/android.xml' would be queued for translations import +● File 'osx_code/zh-Hans.lproj/android.xml' would be queued for translations import +● File 'osx_locale/uk/android.xml' would be queued for translations import +● File 'osx_locale/zh-Hans/android.xml' would be queued for translations import +● File 'three_letters_code/ukr/android.xml' would be queued for translations import +● File 'three_letters_code/zho/android.xml' would be queued for translations import +● File 'two_letters_code/uk/android.xml' would be queued for translations import +● File 'two_letters_code/zh/android.xml' would be queued for translations import +● File 'two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml' would be queued for translations import +● File 'two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml' would be queued for translations import +● Project files fetched +● Project info fetched" +`; + +exports[`translation patterns uploads translations across every placeholder-pattern file group 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'android_code/uk-rUA/android.xml' +● Importing translations for file 'android_code/zh-rCN/android.xml' +● Importing translations for file 'doubled_asterisk/res/values-uk/android.xml' +● Importing translations for file 'doubled_asterisk/res/values-zh/android.xml' +● Importing translations for file 'language/Chinese Simplified/android.xml' +● Importing translations for file 'language/Ukrainian/android.xml' +● Importing translations for file 'locale/uk-UA/android.xml' +● Importing translations for file 'locale/zh-CN/android.xml' +● Importing translations for file 'locale_with_underscore/uk_UA/android.xml' +● Importing translations for file 'locale_with_underscore/zh_CN/android.xml' +● Importing translations for file 'osx_code/uk.lproj/android.xml' +● Importing translations for file 'osx_code/zh-Hans.lproj/android.xml' +● Importing translations for file 'osx_locale/uk/android.xml' +● Importing translations for file 'osx_locale/zh-Hans/android.xml' +● Importing translations for file 'three_letters_code/ukr/android.xml' +● Importing translations for file 'three_letters_code/zho/android.xml' +● Importing translations for file 'two_letters_code/uk/android.xml' +● Importing translations for file 'two_letters_code/zh/android.xml' +● Importing translations for file 'two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml' +● Importing translations for file 'two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml' +● Project files fetched +● Project info fetched +◆ File 'android_code/uk-rUA/android.xml' +◆ File 'android_code/zh-rCN/android.xml' +◆ File 'doubled_asterisk/res/values-uk/android.xml' +◆ File 'doubled_asterisk/res/values-zh/android.xml' +◆ File 'language/Chinese Simplified/android.xml' +◆ File 'language/Ukrainian/android.xml' +◆ File 'locale/uk-UA/android.xml' +◆ File 'locale/zh-CN/android.xml' +◆ File 'locale_with_underscore/uk_UA/android.xml' +◆ File 'locale_with_underscore/zh_CN/android.xml' +◆ File 'osx_code/uk.lproj/android.xml' +◆ File 'osx_code/zh-Hans.lproj/android.xml' +◆ File 'osx_locale/uk/android.xml' +◆ File 'osx_locale/zh-Hans/android.xml' +◆ File 'three_letters_code/ukr/android.xml' +◆ File 'three_letters_code/zho/android.xml' +◆ File 'two_letters_code/uk/android.xml' +◆ File 'two_letters_code/zh/android.xml' +◆ File 'two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml' +◆ File 'two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml'" +`; + +exports[`translation patterns previews the translation download across every placeholder-pattern file group 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ android_code/uk-rUA/android.xml +◆ android_code/zh-rCN/android.xml +◆ doubled_asterisk/res/values-uk/android.xml +◆ doubled_asterisk/res/values-zh/android.xml +◆ language/Chinese Simplified/android.xml +◆ language/Ukrainian/android.xml +◆ locale/uk-UA/android.xml +◆ locale/zh-CN/android.xml +◆ locale_with_underscore/uk_UA/android.xml +◆ locale_with_underscore/zh_CN/android.xml +◆ osx_code/uk.lproj/android.xml +◆ osx_code/zh-Hans.lproj/android.xml +◆ osx_locale/uk/android.xml +◆ osx_locale/zh-Hans/android.xml +◆ three_letters_code/ukr/android.xml +◆ three_letters_code/zho/android.xml +◆ two_letters_code/uk/android.xml +◆ two_letters_code/zh/android.xml +◆ two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml +◆ two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml" +`; + +exports[`translation patterns downloads translations across every placeholder-pattern file group 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'android_code/uk-rUA/android.xml' extracted +◆ File 'android_code/zh-rCN/android.xml' extracted +◆ File 'doubled_asterisk/res/values-uk/android.xml' extracted +◆ File 'doubled_asterisk/res/values-zh/android.xml' extracted +◆ File 'language/Chinese Simplified/android.xml' extracted +◆ File 'language/Ukrainian/android.xml' extracted +◆ File 'locale/uk-UA/android.xml' extracted +◆ File 'locale/zh-CN/android.xml' extracted +◆ File 'locale_with_underscore/uk_UA/android.xml' extracted +◆ File 'locale_with_underscore/zh_CN/android.xml' extracted +◆ File 'osx_code/uk.lproj/android.xml' extracted +◆ File 'osx_code/zh-Hans.lproj/android.xml' extracted +◆ File 'osx_locale/uk/android.xml' extracted +◆ File 'osx_locale/zh-Hans/android.xml' extracted +◆ File 'three_letters_code/ukr/android.xml' extracted +◆ File 'three_letters_code/zho/android.xml' extracted +◆ File 'two_letters_code/uk/android.xml' extracted +◆ File 'two_letters_code/zh/android.xml' extracted +◆ File 'two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml' extracted +◆ File 'two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml' extracted" +`; + +exports[`translation patterns lists configured translation files across every placeholder-pattern file group 1`] = ` +"● Fetching project info +● Project info fetched +◆ android_code/uk-rUA/android.xml +◆ android_code/zh-rCN/android.xml +◆ doubled_asterisk/res/values-uk/android.xml +◆ doubled_asterisk/res/values-zh/android.xml +◆ language/Chinese Simplified/android.xml +◆ language/Ukrainian/android.xml +◆ locale/uk-UA/android.xml +◆ locale/zh-CN/android.xml +◆ locale_with_underscore/uk_UA/android.xml +◆ locale_with_underscore/zh_CN/android.xml +◆ osx_code/uk.lproj/android.xml +◆ osx_code/zh-Hans.lproj/android.xml +◆ osx_locale/uk/android.xml +◆ osx_locale/zh-Hans/android.xml +◆ three_letters_code/ukr/android.xml +◆ three_letters_code/zho/android.xml +◆ two_letters_code/uk/android.xml +◆ two_letters_code/zh/android.xml +◆ two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml +◆ two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml" +`; diff --git a/tests/e2e/suites/__snapshots__/translation-replace.test.ts.snap b/tests/e2e/suites/__snapshots__/translation-replace.test.ts.snap new file mode 100644 index 000000000..e162ea056 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/translation-replace.test.ts.snap @@ -0,0 +1,176 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`translation replace uploads sources, creating the nested directory hierarchy 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'en' +◆ Directory 'en/src' +◆ Directory 'en/src/main' +◆ Directory 'en/src/main/resources' +◆ Directory 'en/src/main/resources/org' +◆ Directory 'en/src/main/resources/org/crowdin' +◆ File 'en/src/main/resources/android.xml' +◆ File 'en/src/main/resources/org/crowdin/android.xml' +◆ File 'en/src/main/resources/org/crowdin/strings.xml'" +`; + +exports[`translation replace updates the existing sources without creating anything new 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'en/src/main/resources/android.xml' +◆ File 'en/src/main/resources/org/crowdin/android.xml' +◆ File 'en/src/main/resources/org/crowdin/strings.xml'" +`; + +exports[`translation replace previews uploading translations as a dry run 1`] = ` +"● Fetching project files +● Fetching project info +● File 'it/src/main/resources/android.xml' would be queued for translations import +● File 'it/src/main/resources/org/crowdin/android.xml' would be queued for translations import +● File 'it/src/main/resources/org/crowdin/strings.xml' would be queued for translations import +● File 'uk/src/main/resources/android.xml' would be queued for translations import +● File 'uk/src/main/resources/org/crowdin/android.xml' would be queued for translations import +● File 'uk/src/main/resources/org/crowdin/strings.xml' would be queued for translations import +● Project files fetched +● Project info fetched" +`; + +exports[`translation replace uploads translations for it and uk 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'it/src/main/resources/android.xml' +● Importing translations for file 'it/src/main/resources/org/crowdin/android.xml' +● Importing translations for file 'it/src/main/resources/org/crowdin/strings.xml' +● Importing translations for file 'uk/src/main/resources/android.xml' +● Importing translations for file 'uk/src/main/resources/org/crowdin/android.xml' +● Importing translations for file 'uk/src/main/resources/org/crowdin/strings.xml' +● Project files fetched +● Project info fetched +◆ File 'it/src/main/resources/android.xml' +◆ File 'it/src/main/resources/org/crowdin/android.xml' +◆ File 'it/src/main/resources/org/crowdin/strings.xml' +◆ File 'uk/src/main/resources/android.xml' +◆ File 'uk/src/main/resources/org/crowdin/android.xml' +◆ File 'uk/src/main/resources/org/crowdin/strings.xml'" +`; + +exports[`translation replace previews downloading translations as a dry run 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ it/src/main/resources/android.xml +◆ it/src/main/resources/org/crowdin/android.xml +◆ it/src/main/resources/org/crowdin/strings.xml +◆ uk/src/main/resources/android.xml +◆ uk/src/main/resources/org/crowdin/android.xml +◆ uk/src/main/resources/org/crowdin/strings.xml" +`; + +exports[`translation replace downloads translations, overwriting the local it/uk trees 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'it/src/main/resources/android.xml' extracted +◆ File 'it/src/main/resources/org/crowdin/android.xml' extracted +◆ File 'it/src/main/resources/org/crowdin/strings.xml' extracted +◆ File 'uk/src/main/resources/android.xml' extracted +◆ File 'uk/src/main/resources/org/crowdin/android.xml' extracted +◆ File 'uk/src/main/resources/org/crowdin/strings.xml' extracted" +`; + +exports[`translation replace uploads sources to a brand-new branch, creating the directory hierarchy again 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch' +◆ Directory 'en' +◆ Directory 'en/src' +◆ Directory 'en/src/main' +◆ Directory 'en/src/main/resources' +◆ Directory 'en/src/main/resources/org' +◆ Directory 'en/src/main/resources/org/crowdin' +◆ File 'en/src/main/resources/android.xml' +◆ File 'en/src/main/resources/org/crowdin/android.xml' +◆ File 'en/src/main/resources/org/crowdin/strings.xml'" +`; + +exports[`translation replace updates sources on the branch (branch already exists) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'en/src/main/resources/android.xml' +◆ File 'en/src/main/resources/org/crowdin/android.xml' +◆ File 'en/src/main/resources/org/crowdin/strings.xml'" +`; + +exports[`translation replace previews uploading translations as a dry run on the branch 1`] = ` +"● Fetching project files +● Fetching project info +● File 'it/src/main/resources/android.xml' would be queued for translations import +● File 'it/src/main/resources/org/crowdin/android.xml' would be queued for translations import +● File 'it/src/main/resources/org/crowdin/strings.xml' would be queued for translations import +● File 'uk/src/main/resources/android.xml' would be queued for translations import +● File 'uk/src/main/resources/org/crowdin/android.xml' would be queued for translations import +● File 'uk/src/main/resources/org/crowdin/strings.xml' would be queued for translations import +● Project files fetched +● Project info fetched" +`; + +exports[`translation replace re-uploads translations to the already-translated master files (no -b) 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'it/src/main/resources/android.xml' +● Importing translations for file 'it/src/main/resources/org/crowdin/android.xml' +● Importing translations for file 'it/src/main/resources/org/crowdin/strings.xml' +● Importing translations for file 'uk/src/main/resources/android.xml' +● Importing translations for file 'uk/src/main/resources/org/crowdin/android.xml' +● Importing translations for file 'uk/src/main/resources/org/crowdin/strings.xml' +● Project files fetched +● Project info fetched +◆ File 'it/src/main/resources/android.xml' +◆ File 'it/src/main/resources/org/crowdin/android.xml' +◆ File 'it/src/main/resources/org/crowdin/strings.xml' +◆ File 'uk/src/main/resources/android.xml' +◆ File 'uk/src/main/resources/org/crowdin/android.xml' +◆ File 'uk/src/main/resources/org/crowdin/strings.xml'" +`; + +exports[`translation replace previews downloading translations on the branch as a dry run 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ it/src/main/resources/android.xml +◆ it/src/main/resources/org/crowdin/android.xml +◆ it/src/main/resources/org/crowdin/strings.xml +◆ uk/src/main/resources/android.xml +◆ uk/src/main/resources/org/crowdin/android.xml +◆ uk/src/main/resources/org/crowdin/strings.xml" +`; + +exports[`translation replace downloads translations on the branch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'it/src/main/resources/android.xml' extracted +◆ File 'it/src/main/resources/org/crowdin/android.xml' extracted +◆ File 'it/src/main/resources/org/crowdin/strings.xml' extracted +◆ File 'uk/src/main/resources/android.xml' extracted +◆ File 'uk/src/main/resources/org/crowdin/android.xml' extracted +◆ File 'uk/src/main/resources/org/crowdin/strings.xml' extracted" +`; diff --git a/tests/e2e/suites/__snapshots__/translations-not-match.test.ts.snap b/tests/e2e/suites/__snapshots__/translations-not-match.test.ts.snap new file mode 100644 index 000000000..5c75c8934 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/translations-not-match.test.ts.snap @@ -0,0 +1,180 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`translations not match uploads sources, alongside a directly-uploaded file the config never covers 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'sources' +◆ File 'sources/1_android.xml' +◆ File 'sources/2_android.xml' +◆ File 'sources/3_android.xml'" +`; + +exports[`translations not match attempts to upload translations for all languages (none exist locally) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched" +`; + +exports[`translations not match attempts to upload translations for a single specified language (uk) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched" +`; + +exports[`translations not match previews downloading translations once the config narrows to a single source file (dry run) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ translations/it/1_android.xml +◆ translations/uk/1_android.xml" +`; + +exports[`translations not match previews the same narrowed dry run as a tree 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +. +╰─ translations + ├─ it + │ ╰─ 1_android.xml + ╰─ uk + ╰─ 1_android.xml" +`; + +exports[`translations not match downloads translations for real, warning about the sources the narrowed config no longer covers 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted + - java.properties (2) + - sources/2_android.xml (2) + - sources/3_android.xml (2) +Visit the https://crowdin.github.io/crowdin-cli/faq for more details" +`; + +exports[`translations not match downloads translations again with --verbose, listing the omitted translation paths 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Loading configuration from '/translations-not-match/crowdin.yml' file +● Project files fetched +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted + - java.properties (2) + - it/java.properties + - uk/java.properties + - sources/2_android.xml (2) + - translations/it/2_android.xml + - translations/uk/2_android.xml + - sources/3_android.xml (2) + - translations/it/3_android.xml + - translations/uk/3_android.xml +Visit the https://crowdin.github.io/crowdin-cli/faq for more details" +`; + +exports[`translations not match downloads translations for a single specified language (uk) 1`] = ` +"● Building translations for languages: uk +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/uk/1_android.xml' extracted + - java.properties (1) + - sources/2_android.xml (1) + - sources/3_android.xml (1) +Visit the https://crowdin.github.io/crowdin-cli/faq for more details" +`; + +exports[`translations not match downloads translations for a single specified language (uk) with --verbose 1`] = ` +"● Building translations for languages: uk +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Loading configuration from '/translations-not-match/crowdin.yml' file +● Project files fetched +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/uk/1_android.xml' extracted + - java.properties (1) + - java.properties + - sources/2_android.xml (1) + - translations/uk/2_android.xml + - sources/3_android.xml (1) + - translations/uk/3_android.xml +Visit the https://crowdin.github.io/crowdin-cli/faq for more details" +`; + +exports[`translations not match uploads sources to a brand-new branch 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test-branch' +◆ Directory 'sources' +◆ File 'sources/1_android.xml' +◆ File 'sources/2_android.xml' +◆ File 'sources/3_android.xml'" +`; + +exports[`translations not match downloads translations for the branch, with the same configuration mismatch 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted + - sources/2_android.xml (2) + - sources/3_android.xml (2) +Visit the https://crowdin.github.io/crowdin-cli/faq for more details" +`; + +exports[`translations not match downloads translations for the branch with --verbose 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project files +● Fetching project info +● Loading configuration from '/translations-not-match/crowdin.yml' file +● Project files fetched +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/1_android.xml' extracted +◆ File 'translations/uk/1_android.xml' extracted + - sources/2_android.xml (2) + - translations/it/2_android.xml + - translations/uk/2_android.xml + - sources/3_android.xml (2) + - translations/it/3_android.xml + - translations/uk/3_android.xml +Visit the https://crowdin.github.io/crowdin-cli/faq for more details" +`; diff --git a/tests/e2e/suites/__snapshots__/upload-single-file.test.ts.snap b/tests/e2e/suites/__snapshots__/upload-single-file.test.ts.snap new file mode 100644 index 000000000..aead54d8d --- /dev/null +++ b/tests/e2e/suites/__snapshots__/upload-single-file.test.ts.snap @@ -0,0 +1,66 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`upload single file uploads a single file via short flag params with no config file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Directory 'sources' +◆ File 'sources/1_android.xml'" +`; + +exports[`upload single file uploads the same file via long flag params with no config file 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/1_android.xml'" +`; + +exports[`upload single file uploads the same file combined with a config file, replacing its files entry 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/1_android.xml'" +`; + +exports[`upload single file uploads the same file combined with a config file and an explicit --dest 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/androidDest.xml'" +`; + +exports[`upload single file uploads an empty file, which is skipped with a warning 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched" +`; + +exports[`upload single file uploads every file from the config's own file group, skipping the empty ones 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/1_android.xml' +◆ File 'sources/2_android.xml'" +`; + +exports[`upload single file uploads an empty file to a new branch, still skipped with a warning 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ Branch 'test'" +`; + +exports[`upload single file uploads the same file again with --preserve-hierarchy explicitly set 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'sources/1_android.xml'" +`; diff --git a/tests/e2e/suites/__snapshots__/without-config-param.test.ts.snap b/tests/e2e/suites/__snapshots__/without-config-param.test.ts.snap new file mode 100644 index 000000000..730b54904 --- /dev/null +++ b/tests/e2e/suites/__snapshots__/without-config-param.test.ts.snap @@ -0,0 +1,83 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`cli commands without an explicit config parameter uploads sources via crowdin.yaml default discovery (no -c) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`cli commands without an explicit config parameter uploads translations via crowdin.yaml default discovery (no -c) 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/android.xml' +● Importing translations for file 'translations/uk/android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/it/android.xml' +◆ File 'translations/uk/android.xml'" +`; + +exports[`cli commands without an explicit config parameter downloads translations via crowdin.yaml default discovery (no -c) 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/uk/android.xml' extracted" +`; + +exports[`cli commands without an explicit config parameter uploads sources via crowdin.yml default discovery (no -c) 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`cli commands without an explicit config parameter uploads translations via crowdin.yml default discovery (no -c) 1`] = ` +"● Fetching project files +● Fetching project info +● Importing translations for file 'translations/it/android.xml' +● Importing translations for file 'translations/uk/android.xml' +● Project files fetched +● Project info fetched +◆ File 'translations/it/android.xml' +◆ File 'translations/uk/android.xml'" +`; + +exports[`cli commands without an explicit config parameter downloads translations via crowdin.yml default discovery (no -c) 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/uk/android.xml' extracted" +`; + +exports[`cli commands without an explicit config parameter uploads sources using only CLI flags, no config file at all 1`] = ` +"● Fetching project files +● Fetching project info +● Project files fetched +● Project info fetched +◆ File 'android.xml'" +`; + +exports[`cli commands without an explicit config parameter downloads translations using only CLI flags, no config file at all 1`] = ` +"● Building translations +● Building translations... +● Downloading translations +● Fetching project info +● Project info fetched +● Translations built +◆ Done +◆ File 'translations/it/android.xml' extracted +◆ File 'translations/uk/android.xml' extracted" +`; diff --git a/tests/e2e/suites/app.test.ts b/tests/e2e/suites/app.test.ts new file mode 100644 index 000000000..3187bac10 --- /dev/null +++ b/tests/e2e/suites/app.test.ts @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `app list` / `app install` / `app uninstall` (`cli/commands/app/AppCommand.ts`). + * + * Installations are account-scoped: none of the three API calls takes a project id. So the suite + * runs `withoutProject`, `teardownSuite` cannot clean up an install, and `app list` reads shared + * account state that must never be snapshotted - the output formats are cross-checked against each + * other instead. + * + * The round trip installs `batch-add-strings`: Crowdin-authored and Crowdin-hosted, `scopes: + * ["project"]` only, one inert menu module, and a manifest `identifier` equal to its Store slug - + * which matters because `install` resolves a slug while `uninstall` passes the string straight to + * the API, and the two need not agree for every app. + */ + +const UNKNOWN_IDENTIFIER = 'crowdin-cli-e2e-no-such-app-xyz'; + +const INSTALLABLE_IDENTIFIER = 'batch-add-strings'; +const INSTALLABLE_NAME = 'Bulk Add Strings'; + +interface ListedApp { + identifier: string; + name: string; +} + +describe('app', () => { + let ctx: SuiteContext; + + let installedByThisRun = false; + let listedApps: ListedApp[]; + + async function listInstalled(): Promise { + return runJson(ctx, ['app', 'list']); + } + + beforeAll(async () => { + ctx = await setupSuite('app', { withoutProject: true }); + }); + + afterAll(async () => { + if (ctx && installedByThisRun && !ctx.env.keep) { + try { + await ctx.client.applicationsApi.deleteApplicationInstallation(INSTALLABLE_IDENTIFIER, true); + } catch { + // Already removed by the round trip. + } + } + + await teardownSuite(ctx); + }); + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['app']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage apps'); + expect(result.stdout).toContain('list'); + expect(result.stdout).toContain('install'); + expect(result.stdout).toContain('uninstall'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['app', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('lists the installed applications as structured data', async () => { + listedApps = await runJson(ctx, ['app', 'list']); + + expect(Array.isArray(listedApps)).toBe(true); + + for (const app of listedApps) { + expect(Object.keys(app).sort()).toEqual(['identifier', 'name']); + expect(typeof app.identifier).toBe('string'); + expect(typeof app.name).toBe('string'); + } + }); + + test('lists identifiers alone with --output plain', async () => { + const result = await ctx.runner.run(['app', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout.split('\n').filter((line) => line.length > 0)).toEqual( + listedApps.map((app) => app.identifier), + ); + }); + + test('renders the same applications in the default text format', async () => { + const result = await ctx.runner.run(['app', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + + if (listedApps.length === 0) { + expect(result.stdout).toContain('No applications found'); + return; + } + + for (const app of listedApps) { + expect(result.stdout).toContain(`${app.identifier} ${app.name}`); + } + }); + + test('requires an identifier to install', async () => { + const result = await ctx.runner.run(['app', 'install']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'identifier'"); + }); + + test('reports an identifier that is not in the Crowdin Store', async () => { + const result = await ctx.runner.run(['app', 'install', UNKNOWN_IDENTIFIER]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + `Application with identifier '${UNKNOWN_IDENTIFIER}' doesn't exist in Crowdin Store`, + ); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test('requires an identifier to uninstall', async () => { + const result = await ctx.runner.run(['app', 'uninstall']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'identifier'"); + }); + + test('fails to uninstall an application that is not installed', async () => { + const result = await ctx.runner.run(['app', 'uninstall', UNKNOWN_IDENTIFIER]); + + expect(result.exitCode).toBe(102); + expect(result.stderr).toContain(`Failed to uninstall application '${UNKNOWN_IDENTIFIER}'`); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test('installs an application from the Crowdin Store', async () => { + // A stale install from a crashed run would otherwise fail below with an opaque API error. + expect(await listInstalled()).not.toContainEqual({ + identifier: INSTALLABLE_IDENTIFIER, + name: INSTALLABLE_NAME, + }); + + const result = await ctx.runner.run(['app', 'install', INSTALLABLE_IDENTIFIER]); + + installedByThisRun = result.exitCode === 0; + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Application has been installed'); + expect(result.stdout).toContain(`${INSTALLABLE_IDENTIFIER} ${INSTALLABLE_NAME}`); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists the newly installed application', async () => { + expect(await listInstalled()).toContainEqual({ + identifier: INSTALLABLE_IDENTIFIER, + name: INSTALLABLE_NAME, + }); + }); + + test('uninstalls the application again', async () => { + const result = await ctx.runner.run(['app', 'uninstall', INSTALLABLE_IDENTIFIER]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Application has been uninstalled'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('no longer lists the uninstalled application', async () => { + expect(await listInstalled()).not.toContainEqual({ + identifier: INSTALLABLE_IDENTIFIER, + name: INSTALLABLE_NAME, + }); + }); + + // Last: switchConfig replaces the config every later run would use. + test('requires project_id even though no subcommand sends one', async () => { + await switchConfig(ctx, 'no-project-id'); + + const result = await ctx.runner.run(['app', 'list']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("Required option 'project_id' is missing"); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test.each([['install'], ['uninstall']])('rejects an empty identifier on %s', async (subcommand) => { + const result = await ctx.runner.run(['app', subcommand, '']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Application identifier can not be empty'); + }); +}); diff --git a/tests/e2e/suites/auto-translate-mt.test.ts b/tests/e2e/suites/auto-translate-mt.test.ts new file mode 100644 index 000000000..e1e3e5f6b --- /dev/null +++ b/tests/e2e/suites/auto-translate-mt.test.ts @@ -0,0 +1,163 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers the machine-translation paths of `auto-translate`; validation and selection flags live in `auto-translate.test.ts`. + * + * `preTranslate` polls to completion, so a zero exit means the server finished the job. But with + * `--no-progress` every poll iteration prints its own line and the count varies per run, so the + * successful runs assert deterministic markers with `toContain` rather than snapshotting. The + * `--engine-id` error never reaches the poll loop and keeps its snapshot. + */ + +/** + * The built-in "Crowdin Translate" engine, the one MT engine that needs no credentials configured. + * Looked up by name because the API exposes no stable id for it. + */ +async function getCrowdinMtEngineId(ctx: SuiteContext): Promise { + const mts = await ctx.client.machineTranslationApi.listMts(); + const crowdinEngine = mts.data.find((mt) => mt.data.name === 'Crowdin Translate'); + + if (!crowdinEngine) { + throw new Error("Could not find the built-in 'Crowdin Translate' MT engine for this test account"); + } + + return crowdinEngine.data.id; +} + +/** + * No `project_id`/`api_token`, so both must come from `--project-id`/`--token`. Written into the + * workspace rather than shipped as a fixture, since `copyFixtures` skips the fixture `config/` directory. + */ +async function writeTokenlessConfig(ctx: SuiteContext): Promise { + const configPath = join(ctx.workspace, 'crowdin-without-token.yml'); + + await Bun.write( + configPath, + [ + 'base_path: "."', + 'base_url: "https://api.crowdin.com"', + 'preserve_hierarchy: false', + '', + 'files:', + ' - source: "/sources/*.xml"', + ' translation: "/translations/%two_letters_code%/%original_file_name%"', + ].join('\n'), + ); + + return configPath; +} + +describe('auto-translate via MT', () => { + let ctx: SuiteContext; + let crowdinMtEngineId: number; + + beforeAll(async () => { + ctx = await setupSuite('auto-translate-mt', { targetLanguageIds: ['uk'] }); + crowdinMtEngineId = await getCrowdinMtEngineId(ctx); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + // Success lines echo the project path, which `preserve_hierarchy: false` flattens to the bare filename. + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + // `--file` takes that flattened project path, not the local `sources/1_android.xml`. + test('pre-translates via translation memory (TM)', async () => { + const result = await ctx.runner.run(['auto-translate', '--file', '1_android.xml', '-l', 'uk', '--method', 'tm']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain('Auto-translation is running...'); + expect(result.stdout).toContain('Auto-translation is finished (100%)'); + }); + + test('requires --engine-id for the MT method', async () => { + const result = await ctx.runner.run(['auto-translate', '-l', 'uk', '--method', 'mt']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Machine Translation should be used with the '--engine-id' parameter"); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test('pre-translates via machine translation (MT) with an explicit engine id', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--file', + '1_android.xml', + '-l', + 'uk', + '--method', + 'mt', + '--engine-id', + String(crowdinMtEngineId), + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain('Auto-translation is running...'); + expect(result.stdout).toContain('Auto-translation is finished (100%)'); + }); + + test('warns when --auto-approve-option is used with the MT method', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--file', + '1_android.xml', + '-l', + 'uk', + '--method', + 'mt', + '--engine-id', + String(crowdinMtEngineId), + '--auto-approve-option', + 'all', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("'--auto-approve-option' is used only for the TM Auto-Translation method"); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain('Auto-translation is finished (100%)'); + }); + + test('pre-translates via TM using --token/--project-id instead of a config file', async () => { + const configPath = await writeTokenlessConfig(ctx); + + const result = await ctx.runner.run( + [ + 'auto-translate', + '--file', + '1_android.xml', + '-l', + 'uk', + '--method', + 'tm', + '--project-id', + String(ctx.project.id), + '--token', + ctx.env.token as string, + '--config', + configPath, + '--no-progress', + '--no-colors', + ], + { noConfig: true }, + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain('Auto-translation is running...'); + expect(result.stdout).toContain('Auto-translation is finished (100%)'); + }); +}); diff --git a/tests/e2e/suites/auto-translate.test.ts b/tests/e2e/suites/auto-translate.test.ts new file mode 100644 index 000000000..9e541160a --- /dev/null +++ b/tests/e2e/suites/auto-translate.test.ts @@ -0,0 +1,362 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { createExtraProject, runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `auto-translate`'s validation order and the flags that select what gets translated + * (`cli/commands/auto-translate/AutoTranslateCommand.ts`). The MT paths belong to + * `auto-translate-mt.test.ts`. + * + * Every real run uses `--method tm`, which needs no engine and finishes against an empty TM. + * `preTranslate` polls to completion, so a zero exit means the server finished the job. + */ +const SOURCE_FILE = '/sources/app.xml'; +const NESTED_FILE = '/sources/nested/extra.xml'; + +describe('auto-translate', () => { + let ctx: SuiteContext; + let stringsBasedProjectId: number; + + beforeAll(async () => { + ctx = await setupSuite('auto-translate'); + // The string-based guards need a project of that kind; this suite's own is file-based. + stringsBasedProjectId = await createExtraProject(ctx, { suite: 'auto-translate-strings', stringsBased: true }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/app.xml'"); + expect(result.stdout).toContain("File 'sources/nested/extra.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('requires --method', async () => { + const result = await ctx.runner.run(['auto-translate']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Missing required option '--method'. Supported values: mt, tm, ai"); + }); + + test('rejects an unsupported --method', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'human']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid value for '--method'. Supported values: mt, tm, ai"); + }); + + // The checks below run in the order `defaultAction` declares them, before the project is loaded. + test('refuses --file together with --directory', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--file', + SOURCE_FILE, + '--directory', + '/sources', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Either '--file' or '--directory' can be specified"); + }); + + test('refuses --language together with --exclude-language', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--language', + 'uk', + '--exclude-language', + 'it', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--language' and '--exclude-language' options can't be used simultaneously"); + }); + + test('restricts --translate-with-perfect-match-only to the TM method', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'mt', + '--engine-id', + '1', + '--translate-with-perfect-match-only', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "'--translate-with-perfect-match-only' only works with the TM auto-translation method", + ); + }); + + test('requires --ai-prompt for the AI method', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'ai']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("AI should be used with the '--ai-prompt' parameter"); + }); + + test('rejects an unsupported --auto-approve-option', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--auto-approve-option', 'sometimes']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Wrong '--auto-approve-option' parameter"); + }); + + // --replace-translations-option declares `choices`, so commander rejects a bad value as a usage + // error (exit 2) and `resolveReplaceTranslationsOption`'s own message never fires. Its sibling + // --auto-approve-option declares none, which is why that one still reports at exit 1 above. + test('rejects an unsupported --replace-translations-option', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--replace-translations-option', + 'sometimes', + ]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('sometimes'); + }); + + test('rejects a language the project does not target', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--language', 'de']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Language(s) 'de' doesn't exist in the project"); + }); + + test('rejects an excluded language the project does not target', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--exclude-language', 'de']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Language(s) 'de' doesn't exist in the project"); + }); + + test('fails on a branch the project does not hold', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '-b', 'no-such-branch']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Branch 'no-such-branch' doesn't exist in the project"); + }); + + test('fails on a single --file the project does not hold', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--file', '/sources/missing.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the '/sources/missing.xml' file"); + }); + + test('finds no files to translate under an empty --directory', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--directory', '/nowhere']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Couldn't find any files to Auto-Translate in the current project"); + }); + + test('translates the whole project', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm']); + + expect(result).toMatchObject({ exitCode: 0 }); + }); + + test('translates a single file', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--file', SOURCE_FILE]); + + expect(result).toMatchObject({ exitCode: 0 }); + }); + + test('warns per missing file and fails at the end when several are given', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--file', + SOURCE_FILE, + '--file', + '/sources/missing.xml', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the '/sources/missing.xml' file"); + expect(result.stderr).toContain('Some of the specified files were not found in the project'); + }); + + test('translates the files of a --directory', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--directory', '/sources/nested']); + + expect(result).toMatchObject({ exitCode: 0 }); + }); + + test('accepts --language all alongside --exclude-language', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--language', + 'all', + '--exclude-language', + 'it', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + }); + + test('warns about a label the project is missing', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--label', 'no-such-label']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("The 'no-such-label' label is missing in the Crowdin project"); + }); + + test('warns about a missing exclude label', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--exclude-label', 'no-such-label']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("The 'no-such-label' label is missing in the Crowdin project"); + }); + + // The request-shaping flags are not freely combinable: the API refuses + // translation-modified-before and replace-translations-option unless the scope covers translated + // strings, and reset-approval-status unless auto-approve and skip-approved are left alone. These + // two runs are the coherent halves of that constraint. + test('accepts the re-translation flags', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--file', + NESTED_FILE, + '--scope', + 'translated', + '--replace-translations-option', + 'auto-translated', + '--translation-modified-before', + '2030-01-01T00:00:00Z', + '--reset-approval-status', + '--duplicate-translations', + '--translate-with-perfect-match-only', + '--priority', + 'high', + '--source-language', + 'en', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + }); + + test('accepts the untranslated-scope flags', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--file', + NESTED_FILE, + '--scope', + 'untranslated', + '--auto-approve-option', + 'perfect-match-only', + '--duplicate-translations', + '--skip-approved-translations', + '--translate-with-perfect-match-only', + '--priority', + 'low', + '--source-language', + 'en', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + }); + + test('surfaces the API refusal when the flags contradict each other', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--file', + NESTED_FILE, + '--scope', + 'untranslated', + '--replace-translations-option', + 'auto-translated', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Key: replaceTranslationsOption'); + expect(result.stderr).toContain('Field cannot be set when [scope] has the current value'); + }); + + test('passes a --translation-modified-before value straight to the API', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--file', + NESTED_FILE, + '--translation-modified-before', + '2030-01-01', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('valid date in ISO 8601 format'); + }); + + test('reports the totals under --verbose', async () => { + const result = await ctx.runner.run(['auto-translate', '--method', 'tm', '--file', SOURCE_FILE, '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + + for (const line of ['- files:', '- phrases:', '- words:', '- skipped:']) { + expect(result.stdout).toContain(line); + } + }); + + test('reports the job in the json output', async () => { + const job = await runJson<{ identifier: string; status: string }>(ctx, [ + 'auto-translate', + '--method', + 'tm', + '--file', + SOURCE_FILE, + ]); + + expect(job.identifier).toBeTruthy(); + expect(job.status).toBe('finished'); + }); + + test('rejects --file against a string-based project', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--file', + SOURCE_FILE, + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('File management is not available for string-based projects'); + }); + + test('requires a branch for a string-based project', async () => { + const result = await ctx.runner.run([ + 'auto-translate', + '--method', + 'tm', + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Branch is required for string-based projects'); + }); +}); diff --git a/tests/e2e/suites/auto-update.test.ts b/tests/e2e/suites/auto-update.test.ts new file mode 100644 index 000000000..f0c841230 --- /dev/null +++ b/tests/e2e/suites/auto-update.test.ts @@ -0,0 +1,85 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { copyFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +const LOCAL_ONLY_STRING = 'local edit that --no-auto-update must not upload'; + +describe('auto update', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('auto-update'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources, creating both files', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates existing sources and creates a new one (auto-update is the default)', async () => { + await copyFile(join(ctx.workspace, 'sources_rev2/1_android.xml'), join(ctx.workspace, 'sources/1_android.xml')); + await copyFile(join(ctx.workspace, 'sources_rev2/2_android.xml'), join(ctx.workspace, 'sources/2_android.xml')); + await copyFile(join(ctx.workspace, 'sources_rev2/3_android.xml'), join(ctx.workspace, 'sources/3_android.xml')); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(result.stdout).toContain("File '3_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // The success line above is identical on the create and the update path, so stdout alone can't + // tell an auto-update from a silent skip. Read the strings back instead: rev2 of 1_android.xml + // rewrites every value and adds a sixth string, none of which can be present unless the existing + // file was really updated. + const texts = await projectStringTexts(ctx); + + expect(texts).toContain('sixth string source revision2 file1'); + expect(texts).toContain('first string source revision2 file1'); + expect(texts).not.toContain('first string source file1'); + }); + + test('skips existing sources but still creates a new one with --no-auto-update', async () => { + await copyFile(join(ctx.workspace, 'sources_rev2/4_android.xml'), join(ctx.workspace, 'sources/4_android.xml')); + // 1_android.xml is already at rev2 both locally and server-side, so skipping and updating would + // leave identical content and a state check couldn't tell them apart. Give it a local-only string + // first: the skip is real only if this never reaches the project. + await Bun.write( + join(ctx.workspace, 'sources/1_android.xml'), + '\n\n' + + ` ${LOCAL_ONLY_STRING}\n\n`, + ); + + const result = await ctx.runner.run(['upload', 'sources', '--no-auto-update']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml' already exists and will not be updated"); + expect(result.stdout).toContain("File '2_android.xml' already exists and will not be updated"); + expect(result.stdout).toContain("File '3_android.xml' already exists and will not be updated"); + expect(result.stdout).toContain("File '4_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const texts = await projectStringTexts(ctx); + + expect(texts).not.toContain(LOCAL_ONLY_STRING); + // The skipped file keeps the rev2 content test 2 uploaded, and the new file is still created. + expect(texts).toContain('sixth string source revision2 file1'); + expect(texts).toContain('first string'); + }); + + async function projectStringTexts(ctx: SuiteContext): Promise<(string | undefined)[]> { + const response = await ctx.client.sourceStringsApi.withFetchAll().listProjectStrings(ctx.project.id, {}); + return response.data.map((entry) => ('text' in entry.data ? (entry.data.text as string) : undefined)); + } +}); diff --git a/tests/e2e/suites/base-path.test.ts b/tests/e2e/suites/base-path.test.ts new file mode 100644 index 000000000..23973d2ba --- /dev/null +++ b/tests/e2e/suites/base-path.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { captureAndClear, expectRestored } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +async function deleteAllProjectFiles(ctx: SuiteContext): Promise { + const files = await ctx.client.sourceFilesApi.withFetchAll().listProjectFiles(ctx.project.id); + for (const file of files.data) { + await ctx.client.sourceFilesApi.deleteFile(ctx.project.id, file.data.id); + } +} + +describe('base path', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('base-path', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources with an explicit --base-path, creating the directory hierarchy', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--base-path', '.']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'files'"); + expect(result.stdout).toContain("Directory 'files/src'"); + expect(result.stdout).toContain("Directory 'files/src/main'"); + expect(result.stdout).toContain("Directory 'files/src/main/res'"); + expect(result.stdout).toContain("Directory 'files/src/main/res/values'"); + expect(result.stdout).toContain("File 'files/src/main/res/values/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates the existing source file at the same base path', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--base-path', '.']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'files/src/main/res/values/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations at the base path', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--base-path', '.']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'files/src/main/res/values-it/android.xml'"); + expect(result.stdout).toContain("Importing translations for file 'files/src/main/res/values-uk/android.xml'"); + expect(result.stdout).toContain("File 'files/src/main/res/values-it/android.xml'"); + expect(result.stdout).toContain("File 'files/src/main/res/values-uk/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations at the base path', async () => { + const captured = await captureAndClear( + ctx.workspace, + 'files/src/main/res/values-it/android.xml', + 'files/src/main/res/values-uk/android.xml', + ); + + const result = await ctx.runner.run(['download', 'translations', '--base-path', '.']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectRestored(ctx.workspace, captured); + }); + + test('lists project source files with --base-path', async () => { + const result = await ctx.runner.run(['file', 'list', '--base-path', '.']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('files/src/main/res/values/android.xml'); + }); + + test('lists configured source files with --base-path', async () => { + const result = await ctx.runner.run(['config', 'sources', '--base-path', '.']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists configured translation files with --base-path', async () => { + const result = await ctx.runner.run(['config', 'translations', '--base-path', '.']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources to a new branch under a different base path', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'dev', '--base-path', 'dev']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'files'"); + expect(result.stdout).toContain("Directory 'files/src'"); + expect(result.stdout).toContain("Directory 'files/src/main'"); + expect(result.stdout).toContain("Directory 'files/src/main/res'"); + expect(result.stdout).toContain("Directory 'files/src/main/res/values'"); + expect(result.stdout).toContain("File 'files/src/main/res/values/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates sources on the branch (branch already exists)', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'dev', '--base-path', 'dev']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'files/src/main/res/values/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations on the branch', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-b', 'dev', '--base-path', 'dev']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'files/src/main/res/values-it/android.xml'"); + expect(result.stdout).toContain("File 'files/src/main/res/values-uk/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations on the branch', async () => { + const captured = await captureAndClear( + ctx.workspace, + 'dev/files/src/main/res/values-it/android.xml', + 'dev/files/src/main/res/values-uk/android.xml', + ); + + const result = await ctx.runner.run(['download', 'translations', '-b', 'dev', '--base-path', 'dev']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectRestored(ctx.workspace, captured); + }); + + test('uploads sources with a relative --base-path pointing into a subdirectory', async () => { + await switchConfig(ctx, 'relative-base-path'); + await deleteAllProjectFiles(ctx); + + const result = await ctx.runner.run(['upload', 'sources', '--base-path', './files']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'src/main/res/values/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations with a relative --base-path', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--base-path', './files']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'src/main/res/values-it/android.xml'"); + expect(result.stdout).toContain("File 'src/main/res/values-uk/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations with a relative --base-path', async () => { + const captured = await captureAndClear( + ctx.workspace, + 'files/src/main/res/values-it/android.xml', + 'files/src/main/res/values-uk/android.xml', + ); + + const result = await ctx.runner.run(['download', 'translations', '--base-path', './files']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectRestored(ctx.workspace, captured); + }); +}); diff --git a/e2e/suites/basic-upload-download.test.ts b/tests/e2e/suites/basic-upload-download.test.ts similarity index 76% rename from e2e/suites/basic-upload-download.test.ts rename to tests/e2e/suites/basic-upload-download.test.ts index 4818db740..f858dc455 100644 --- a/e2e/suites/basic-upload-download.test.ts +++ b/tests/e2e/suites/basic-upload-download.test.ts @@ -17,31 +17,31 @@ describe('basic upload sources and download translations', () => { test('uploads all source files to a fresh project', async () => { const result = await ctx.runner.run(['upload', 'sources']); - expect(result.exitCode).toBe(0); + expect(result).toMatchObject({ exitCode: 0 }); expect(normalize(result.stdout)).toMatchSnapshot(); }); test('updates existing source files', async () => { const result = await ctx.runner.run(['upload', 'sources']); - expect(result.exitCode).toBe(0); + expect(result).toMatchObject({ exitCode: 0 }); expect(normalize(result.stdout)).toMatchSnapshot(); }); test('downloads translations for every target language', async () => { const result = await ctx.runner.run(['download', 'translations']); - expect(result.exitCode).toBe(0); + expect(result).toMatchObject({ exitCode: 0 }); expect(normalize(result.stdout)).toMatchSnapshot(); await expectFilesExist( ctx.workspace, - 'it/sources/alpha.md', - 'it/sources/beta.md', - 'it/sources/gamma.md', - 'uk/sources/alpha.md', - 'uk/sources/beta.md', - 'uk/sources/gamma.md', + 'translations/it-IT/alpha.md', + 'translations/it-IT/beta.md', + 'translations/it-IT/gamma.md', + 'translations/uk-UA/alpha.md', + 'translations/uk-UA/beta.md', + 'translations/uk-UA/gamma.md', ); }); }); diff --git a/tests/e2e/suites/branch.test.ts b/tests/e2e/suites/branch.test.ts new file mode 100644 index 000000000..2a5bf9197 --- /dev/null +++ b/tests/e2e/suites/branch.test.ts @@ -0,0 +1,372 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { decode } from '@toon-format/toon'; +import { findBranch } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { createExtraProject, runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers the `branch` command itself (`cli/commands/branch/BranchCommand.ts`). `branches.test.ts` + * covers the `-b` flag of upload/download. + * + * The project is strings-based, because `clone` and `merge` refuse to run against any other type. + * That also makes the merge assertable without a file round trip: `string add -b` puts strings on + * the source branch, and the merge has to carry them into the target. + */ +/** A strings-based project is created with this branch already in it. */ +const DEFAULT_BRANCH = 'main'; +const MAIN_BRANCH = 'main-line'; +const FEATURE_BRANCH = 'feature'; +const RENAMED_BRANCH = 'feature-renamed'; +const SLASHED_BRANCH = 'feature/login'; +const NORMALIZED_BRANCH = 'feature.login'; +const CLONE_TARGET = 'cloned'; +const MERGE_SOURCE = 'to-merge'; +const STRUCTURED_BRANCH = 'structured'; +const MERGED_STRING = 'String added on the merge source branch'; + +describe('branch', () => { + let ctx: SuiteContext; + let fileBasedProjectId: number; + + beforeAll(async () => { + ctx = await setupSuite('branch', { stringsBased: true }); + // The strings-based guard needs a project of the other kind to fire against; the suite's own + // project cannot be it. + fileBasedProjectId = await createExtraProject(ctx, { suite: 'branch-file-based' }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + async function listBranchNames(projectId = ctx.project.id): Promise { + const response = await ctx.client.sourceFilesApi.withFetchAll().listProjectBranches(projectId); + + return response.data.map((entry) => entry.data.name).sort(); + } + + async function branchStringTexts(branchName: string): Promise { + const branch = await findBranch(ctx, branchName); + const response = await ctx.client.sourceStringsApi + .withFetchAll() + .listProjectStrings(ctx.project.id, { branchId: branch.id }); + + return response.data.map((entry) => entry.data.text as string).sort(); + } + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage branches in a Crowdin project'); + + for (const subcommand of ['list', 'add', 'delete', 'edit', 'clone', 'merge']) { + expect(result.stdout).toContain(subcommand); + } + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['branch', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + // A strings-based project is never branch-free: Crowdin creates 'main' with the project, so the + // 'No branches found' empty state is out of reach here. + test('lists the branch a new project starts with', async () => { + const result = await ctx.runner.run(['branch', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(DEFAULT_BRANCH); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('requires a branch name on add', async () => { + const result = await ctx.runner.run(['branch', 'add']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'name'"); + }); + + test('adds a branch', async () => { + const result = await ctx.runner.run(['branch', 'add', MAIN_BRANCH]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(MAIN_BRANCH); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await listBranchNames()).toEqual([DEFAULT_BRANCH, MAIN_BRANCH]); + }); + + test('warns instead of failing when the branch already exists', async () => { + const result = await ctx.runner.run(['branch', 'add', MAIN_BRANCH]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain(`Branch '${MAIN_BRANCH}' already exists in the project`); + expect(await listBranchNames()).toEqual([DEFAULT_BRANCH, MAIN_BRANCH]); + }); + + test('adds a branch with a title', async () => { + const result = await ctx.runner.run(['branch', 'add', FEATURE_BRANCH, '--title', 'Feature work']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect((await findBranch(ctx, FEATURE_BRANCH)).title).toBe('Feature work'); + }); + + // --priority and --export-pattern are file-based concepts: the API answers "Field 'priority' is + // unexpected" for a strings-based project, so they are exercised against the other project. + test('adds a branch with a priority and an export pattern in a file-based project', async () => { + const result = await ctx.runner.run([ + 'branch', + 'add', + 'prioritized', + '--priority', + 'high', + '--export-pattern', + '/%two_letters_code%/%original_file_name%', + '--project-id', + String(fileBasedProjectId), + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + + const branch = await findBranch(ctx, 'prioritized', fileBasedProjectId); + + expect(branch.priority).toBe('high'); + expect(branch.exportPattern).toBe('/%two_letters_code%/%original_file_name%'); + }); + + test('edits the priority of a file-based project branch', async () => { + const result = await ctx.runner.run([ + 'branch', + 'edit', + 'prioritized', + '--priority', + 'low', + '--project-id', + String(fileBasedProjectId), + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect((await findBranch(ctx, 'prioritized', fileBasedProjectId)).priority).toBe('low'); + }); + + test('rejects an unsupported --priority value', async () => { + const result = await ctx.runner.run(['branch', 'add', 'bad-priority', '--priority', 'urgent']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('urgent'); + }); + + // Crowdin refuses the separators a VCS branch name is full of, so the CLI replaces them with dots + // and keeps the name the user typed as the title (parsing.ts `normalizeBranchName`). + test('normalizes a branch name and keeps the original as the title', async () => { + const result = await ctx.runner.run(['branch', 'add', SLASHED_BRANCH]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(NORMALIZED_BRANCH); + + const branch = await findBranch(ctx, NORMALIZED_BRANCH); + + expect(branch.title).toBe(SLASHED_BRANCH); + }); + + test('lists every branch', async () => { + const result = await ctx.runner.run(['branch', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + + for (const name of [DEFAULT_BRANCH, MAIN_BRANCH, FEATURE_BRANCH, NORMALIZED_BRANCH]) { + expect(result.stdout).toContain(name); + } + }); + + test('lists branch names only in the plain output', async () => { + const result = await ctx.runner.run(['branch', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout.trim().split('\n').sort()).toEqual( + [DEFAULT_BRANCH, FEATURE_BRANCH, MAIN_BRANCH, NORMALIZED_BRANCH].sort(), + ); + }); + + test('lists branches as structured data', async () => { + const branches = await runJson<{ id: number; name: string }[]>(ctx, ['branch', 'list']); + + expect(branches.map((branch) => branch.name).sort()).toEqual( + [DEFAULT_BRANCH, FEATURE_BRANCH, MAIN_BRANCH, NORMALIZED_BRANCH].sort(), + ); + }); + + // Runs after the two tests above, which assert the exact branch set - adding a branch before them + // would break both. + test.each(['json', 'toon'] as const)('echoes a created branch as one %s object, not a list', async (format) => { + const name = `${STRUCTURED_BRANCH}-${format}`; + const result = await ctx.runner.run(['branch', 'add', name, '--output', format]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(format === 'json' ? JSON.parse(result.stdout) : decode(result.stdout)).toEqual({ + id: expect.any(Number), + name, + }); + }); + + test('requires at least one parameter on edit', async () => { + const result = await ctx.runner.run(['branch', 'edit', MAIN_BRANCH]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Specify some parameters to edit the branch'); + }); + + test('fails to edit a branch that does not exist', async () => { + const result = await ctx.runner.run(['branch', 'edit', 'no-such-branch', '--title', 'Nope']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the 'no-such-branch' branch"); + }); + + test('renames a branch', async () => { + const result = await ctx.runner.run(['branch', 'edit', FEATURE_BRANCH, '--name', RENAMED_BRANCH]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(RENAMED_BRANCH); + expect(await listBranchNames()).toContain(RENAMED_BRANCH); + expect(await listBranchNames()).not.toContain(FEATURE_BRANCH); + }); + + test('edits the title', async () => { + const result = await ctx.runner.run(['branch', 'edit', RENAMED_BRANCH, '--title', 'Renamed feature']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect((await findBranch(ctx, RENAMED_BRANCH)).title).toBe('Renamed feature'); + }); + + test('clones a branch', async () => { + const result = await ctx.runner.run(['branch', 'clone', MAIN_BRANCH, CLONE_TARGET]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(CLONE_TARGET); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await listBranchNames()).toContain(CLONE_TARGET); + }); + + test('fails to clone a branch that does not exist', async () => { + const result = await ctx.runner.run(['branch', 'clone', 'no-such-branch', 'whatever']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the 'no-such-branch' branch"); + }); + + test('requires both names on merge', async () => { + const result = await ctx.runner.run(['branch', 'merge', MAIN_BRANCH]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'target'"); + }); + + test('merges a branch, carrying its strings into the target', async () => { + const add = await ctx.runner.run(['branch', 'add', MERGE_SOURCE]); + + expect(add).toMatchObject({ exitCode: 0 }); + + const addString = await ctx.runner.run(['string', 'add', MERGED_STRING, '-b', MERGE_SOURCE]); + + expect(addString).toMatchObject({ exitCode: 0 }); + + const dryRun = await ctx.runner.run(['branch', 'merge', MERGE_SOURCE, MAIN_BRANCH, '--dryrun']); + + expect(dryRun).toMatchObject({ exitCode: 0 }); + expect(dryRun.stdout).toContain(`Merged branch '${MERGE_SOURCE}' into '${MAIN_BRANCH}'`); + expect(await branchStringTexts(MAIN_BRANCH)).toEqual([]); + + const result = await ctx.runner.run(['branch', 'merge', MERGE_SOURCE, MAIN_BRANCH]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Merge summary'); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await branchStringTexts(MAIN_BRANCH)).toEqual([MERGED_STRING]); + expect(await listBranchNames()).toContain(MERGE_SOURCE); + }); + + test('deletes the source branch with --delete-after-merge', async () => { + const result = await ctx.runner.run(['branch', 'merge', MERGE_SOURCE, MAIN_BRANCH, '--delete-after-merge']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(await listBranchNames()).not.toContain(MERGE_SOURCE); + }); + + test('warns instead of failing when deleting a branch that does not exist', async () => { + const result = await ctx.runner.run(['branch', 'delete', 'no-such-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("Branch 'no-such-branch' doesn't exist in the project"); + }); + + test.each(['json', 'toon'] as const)('reports that warning as a %s record with no exit code', async (format) => { + const result = await ctx.runner.run(['branch', 'delete', 'no-such-branch', '--output', format]); + const parse = format === 'json' ? JSON.parse : decode; + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout.trim()).toBe(''); + expect(parse(result.stderr)).toEqual({ + level: 'warning', + message: "Branch 'no-such-branch' doesn't exist in the project", + }); + }); + + test('deletes a branch', async () => { + const result = await ctx.runner.run(['branch', 'delete', CLONE_TARGET]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`Branch '${CLONE_TARGET}' deleted`); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await listBranchNames()).not.toContain(CLONE_TARGET); + }); + + test('refuses to clone in a file-based project', async () => { + const result = await ctx.runner.run([ + 'branch', + 'clone', + MAIN_BRANCH, + 'whatever', + '--project-id', + String(fileBasedProjectId), + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('This command is only available for string-based projects'); + }); + + test('refuses to merge in a file-based project', async () => { + const result = await ctx.runner.run([ + 'branch', + 'merge', + MAIN_BRANCH, + 'whatever', + '--project-id', + String(fileBasedProjectId), + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('This command is only available for string-based projects'); + }); + + test.each([['add'], ['delete'], ['edit']])('rejects an empty branch name on %s', async (subcommand) => { + const result = await ctx.runner.run(['branch', subcommand, '']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Branch name is required'); + }); + + test.each([ + ['an empty source', ['', MAIN_BRANCH]], + ['an empty target', [MAIN_BRANCH, '']], + ])('rejects %s on clone and merge', async (_label, args) => { + for (const subcommand of ['clone', 'merge']) { + const result = await ctx.runner.run(['branch', subcommand, ...(args as string[])]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Source and target branch names are required'); + } + }); +}); diff --git a/tests/e2e/suites/branches.test.ts b/tests/e2e/suites/branches.test.ts new file mode 100644 index 000000000..434d68c53 --- /dev/null +++ b/tests/e2e/suites/branches.test.ts @@ -0,0 +1,121 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { copyFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +describe('branches', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('branches', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads a single source file to a brand-new branch', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test_list_string']); + + expect(result).toMatchObject({ exitCode: 0 }); + // Success echoes the PROJECT path, and every config in this fixture sets `preserve_hierarchy: false`, + // so `/sources_one_file/1_android.xml` lands as `1_android.xml`. The dry runs below print the local path. + expect(result.stdout).toContain("File '1_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews uploading two more source files to a not-yet-existing branch (dry run)', async () => { + await switchConfig(ctx, 'sources'); + + const result = await ctx.runner.run(['upload', 'sources', '--dryrun', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the source upload dry run as a tree', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--dryrun', '--tree', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads the two source files for real, creating the branch', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates the existing sources after local changes', async () => { + await copyFile( + join(ctx.workspace, 'sources_rev2', '1_android.xml'), + join(ctx.workspace, 'sources', '1_android.xml'), + ); + await copyFile( + join(ctx.workspace, 'sources_rev2', '2_android.xml'), + join(ctx.workspace, 'sources', '2_android.xml'), + ); + + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation upload as a dry run', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation dry run as a tree', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun', '--tree', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for the updated sources', async () => { + await switchConfig(ctx, 'sources-rev2'); + + const result = await ctx.runner.run(['upload', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'translations/it/1_android.xml'"); + expect(result.stdout).toContain("Importing translations for file 'translations/it/2_android.xml'"); + expect(result.stdout).toContain("Importing translations for file 'translations/uk/1_android.xml'"); + expect(result.stdout).toContain("Importing translations for file 'translations/uk/2_android.xml'"); + expect(result.stdout).toContain("File 'translations/it/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/it/2_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for the branch', async () => { + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); +}); diff --git a/tests/e2e/suites/bundle.test.ts b/tests/e2e/suites/bundle.test.ts new file mode 100644 index 000000000..5a3beb0ce --- /dev/null +++ b/tests/e2e/suites/bundle.test.ts @@ -0,0 +1,375 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * `bundle browse` is not covered, for the reason `project browse` is not: `browseAction` calls + * `openUrl`, which spawns a real `open`/`xdg-open`, so a test would pop a browser tab on every run. + */ +describe('bundle', () => { + let ctx: SuiteContext; + let bundleId: string; + let clonedBundleId: string; + let flaggedBundleId: string; + + beforeAll(async () => { + ctx = await setupSuite('bundle', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + /** + * The bundle as the API holds it. `includeInContextPseudoLanguage` is absent from the client's + * `Bundle` model but is returned by the API, so it is read off a widened type. + */ + async function apiBundle(id: string | number) { + const response = await ctx.client.bundlesApi.getBundle(ctx.project.id, Number(id)); + + return response.data as (typeof response)['data'] & { includeInContextPseudoLanguage?: boolean }; + } + + async function listedBundles(): Promise<{ id: number; name: string; format: string }[]> { + return runJson<{ id: number; name: string; format: string }[]>(ctx, ['bundle', 'list']); + } + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['bundle']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage bundles'); + + for (const subcommand of ['list', 'add', 'delete', 'download', 'clone', 'browse']) { + expect(result.stdout).toContain(subcommand); + } + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['bundle', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('reports an empty bundle list', async () => { + const result = await ctx.runner.run(['bundle', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('No bundles found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources for the bundle', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sample.json'"); + expect(result.stdout).toContain("File 'sample.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds a bundle', async () => { + const result = await ctx.runner.run([ + 'bundle', + 'add', + 'RegularBundle', + '--format', + 'macosx', + '--source-pattern', + '**', + '--export-pattern', + 'all.string', + ]); + // Text renders `# `. + bundleId = result.stdout.match(/#(\d+)/)?.[1] ?? ''; + + expect(result).toMatchObject({ exitCode: 0 }); + expect(bundleId).not.toBe(''); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds a bundle with plain output', async () => { + const result = await ctx.runner.run([ + 'bundle', + 'add', + 'BundleCreatedWithPlainOutput', + '--format', + 'xliff', + '--source-pattern', + '**', + '--export-pattern', + 'all.xliff', + '--output', + 'plain', + ]); + const plainLine = normalize(result.stdout); + const localBundleId = plainLine.match(/^(\d+)\b/)?.[1] ?? ''; + + expect(result).toMatchObject({ exitCode: 0 }); + expect(localBundleId).not.toBe(''); + expect(plainLine).toBe(`${localBundleId} BundleCreatedWithPlainOutput`); + expect(maskBundleId(plainLine, localBundleId)).toMatchSnapshot(); + }); + + test('downloads the bundle', async () => { + const result = await ctx.runner.run(['bundle', 'download', bundleId]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`#${bundleId} 'RegularBundle' has been successfully downloaded`); + expect(result.stdout).toContain('it/all.string'); + expect(result.stdout).toContain('uk/all.string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await sortedLines(join(ctx.workspace, 'files/it/all.string'))).toEqual( + await sortedLines(join(ctx.workspace, 'expected/it_all.string')), + ); + expect(await sortedLines(join(ctx.workspace, 'files/uk/all.string'))).toEqual( + await sortedLines(join(ctx.workspace, 'expected/uk_all.string')), + ); + }); + + test('requires a bundle name', async () => { + const result = await ctx.runner.run(['bundle', 'add']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'name'"); + }); + + test('requires --format, --source-pattern and --export-pattern', async () => { + const missingFormat = await ctx.runner.run(['bundle', 'add', 'Incomplete']); + + expect(missingFormat.exitCode).toBe(1); + expect(missingFormat.stderr).toContain("'--format' can't be empty"); + + const missingSource = await ctx.runner.run(['bundle', 'add', 'Incomplete', '--format', 'xliff']); + + expect(missingSource.exitCode).toBe(1); + expect(missingSource.stderr).toContain("'--source-pattern' can't be empty"); + + const missingExport = await ctx.runner.run([ + 'bundle', + 'add', + 'Incomplete', + '--format', + 'xliff', + '--source-pattern', + '**', + ]); + + expect(missingExport.exitCode).toBe(1); + expect(missingExport.stderr).toContain("'--export-pattern' can't be empty"); + }); + + test('lists every bundle', async () => { + const result = await ctx.runner.run(['bundle', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('RegularBundle'); + expect(result.stdout).toContain('BundleCreatedWithPlainOutput'); + + expect((await listedBundles()).map((bundle) => bundle.name).sort()).toEqual([ + 'BundleCreatedWithPlainOutput', + 'RegularBundle', + ]); + }); + + // Every clone option is tri-state: omitted means "inherit from the source bundle". + test('clones a bundle, inheriting its settings', async () => { + const result = await ctx.runner.run(['bundle', 'clone', bundleId]); + + expect(result).toMatchObject({ exitCode: 0 }); + + clonedBundleId = result.stdout.match(/#(\d+)/)?.[1] ?? ''; + + expect(clonedBundleId).not.toBe(''); + expect(clonedBundleId).not.toBe(bundleId); + + const clone = (await listedBundles()).find((bundle) => bundle.id === Number(clonedBundleId)); + + expect(clone?.name).toBe('RegularBundle (clone)'); + expect(clone?.format).toBe('macosx'); + }); + + test('clones a bundle with overrides', async () => { + const result = await ctx.runner.run([ + 'bundle', + 'clone', + bundleId, + '--name', + 'OverriddenClone', + '--format', + 'xliff', + '--export-pattern', + 'all.xliff', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + + const id = Number(result.stdout.match(/#(\d+)/)?.[1] ?? ''); + const clone = (await listedBundles()).find((bundle) => bundle.id === id); + + expect(clone?.name).toBe('OverriddenClone'); + expect(clone?.format).toBe('xliff'); + }); + + test('warns instead of failing when cloning an unknown bundle', async () => { + const result = await ctx.runner.run(['bundle', 'clone', '1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("Couldn't find bundle by the specified ID"); + }); + + test('rejects a non-numeric bundle id', async () => { + const result = await ctx.runner.run(['bundle', 'delete', 'abc']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Bundle id must be numeric'); + }); + + test('warns instead of failing when deleting an unknown bundle', async () => { + const result = await ctx.runner.run(['bundle', 'delete', '1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("Couldn't find bundle by the specified ID"); + }); + + test('fails to download an unknown bundle', async () => { + const result = await ctx.runner.run(['bundle', 'download', '1']); + + expect(result.exitCode).toBe(102); + expect(result.stderr).toContain("Couldn't find bundle by the specified ID"); + }); + + test('deletes a bundle', async () => { + const result = await ctx.runner.run(['bundle', 'delete', clonedBundleId]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`Bundle #${clonedBundleId} deleted`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect((await listedBundles()).map((bundle) => bundle.id)).not.toContain(Number(clonedBundleId)); + }); + + // `add` declares only the flag that changes the request: the bundle is created with the + // pseudo-language included and the other two off, so only --no-include-pseudo-language, + // --include-source-language and --multilingual exist there. + test('applies the add defaults when no flag is given', async () => { + const result = await ctx.runner.run([ + 'bundle', + 'add', + 'DefaultFlags', + '--format', + 'xliff', + '--source-pattern', + '**', + '--export-pattern', + 'default.xliff', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + + const bundle = await apiBundle(result.stdout.match(/#(\d+)/)?.[1] ?? ''); + + expect(bundle.includeProjectSourceLanguage).toBe(false); + expect(bundle.includeInContextPseudoLanguage).toBe(true); + expect(bundle.isMultilingual).toBe(false); + }); + + test('honours every add flag', async () => { + const result = await ctx.runner.run([ + 'bundle', + 'add', + 'AllFlags', + '--format', + 'xliff', + '--source-pattern', + '**', + '--export-pattern', + 'all-flags.xliff', + '--ignore-pattern', + '**/ignored.json', + '--include-source-language', + '--no-include-pseudo-language', + '--multilingual', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + + flaggedBundleId = result.stdout.match(/#(\d+)/)?.[1] ?? ''; + + const bundle = await apiBundle(flaggedBundleId); + + expect(bundle.includeProjectSourceLanguage).toBe(true); + expect(bundle.includeInContextPseudoLanguage).toBe(false); + expect(bundle.isMultilingual).toBe(true); + expect(bundle.ignorePatterns).toEqual(['**/ignored.json']); + }); + + test('inherits every flag on a clone', async () => { + const result = await ctx.runner.run(['bundle', 'clone', flaggedBundleId, '--name', 'InheritedFlags']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const bundle = await apiBundle(result.stdout.match(/#(\d+)/)?.[1] ?? ''); + + expect(bundle.includeProjectSourceLanguage).toBe(true); + expect(bundle.includeInContextPseudoLanguage).toBe(false); + expect(bundle.isMultilingual).toBe(true); + expect(bundle.ignorePatterns).toEqual(['**/ignored.json']); + }); + + // The reason clone declares a negation for every flag: without one, an inherited `true` could + // never be turned back off. + test('turns an inherited flag back off on a clone', async () => { + const result = await ctx.runner.run([ + 'bundle', + 'clone', + flaggedBundleId, + '--name', + 'NegatedFlags', + '--no-include-source-language', + '--include-pseudo-language', + '--no-multilingual', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + + const bundle = await apiBundle(result.stdout.match(/#(\d+)/)?.[1] ?? ''); + + expect(bundle.includeProjectSourceLanguage).toBe(false); + expect(bundle.includeInContextPseudoLanguage).toBe(true); + expect(bundle.isMultilingual).toBe(false); + }); + + test('overrides the inherited ignore patterns on a clone', async () => { + const result = await ctx.runner.run([ + 'bundle', + 'clone', + flaggedBundleId, + '--name', + 'OverriddenPatterns', + '--ignore-pattern', + '**/other.json', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect((await apiBundle(result.stdout.match(/#(\d+)/)?.[1] ?? '')).ignorePatterns).toEqual(['**/other.json']); + }); +}); + +/** + * Bundle ids are assigned by the server and are not project-scoped, so they differ on every run. + * `normalize` only masks `#123`-style ids, which leaves the bare id in `--output plain`. + */ +function maskBundleId(output: string, id: string): string { + return output.replaceAll(new RegExp(`\\b${id}\\b`, 'g'), ''); +} + +async function sortedLines(path: string): Promise { + const content = await Bun.file(path).text(); + return content + .split('\n') + .filter((line) => line.length > 0) + .sort(); +} diff --git a/tests/e2e/suites/comment.test.ts b/tests/e2e/suites/comment.test.ts new file mode 100644 index 000000000..78bf00e5f --- /dev/null +++ b/tests/e2e/suites/comment.test.ts @@ -0,0 +1,337 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { findCommentId, findStringId } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `comment add` / `list` / `resolve` (`cli/commands/comment/CommentCommand.ts`). + * + * `string.test.ts` already walks the happy path as part of its own scenario; this suite owns the + * command's own surface - CLI-side validation, the `--type`/`--issue-type`/`--status` filter + * matrix, the output formats, and the failure exit codes. + */ +describe('comment', () => { + let ctx: SuiteContext; + let welcomeStringId: number; + let farewellStringId: number; + let translationMistakeId: number; + + beforeAll(async () => { + ctx = await setupSuite('comment'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['comment']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage string comments and issues'); + expect(result.stdout).toContain('add'); + expect(result.stdout).toContain('list'); + expect(result.stdout).toContain('resolve'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['comment', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('reports no comments before any exist', async () => { + const result = await ctx.runner.run(['comment', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('No comments found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + welcomeStringId = await findStringId(ctx, 'Welcome aboard'); + farewellStringId = await findStringId(ctx, 'See you next time'); + }); + + // `text` is declared as a required positional (builder.ts wraps every argument in `<>`), so + // commander rejects it as a usage error before CommentCommand's own emptiness check runs. + test('requires the comment text', async () => { + const result = await ctx.runner.run(['comment', 'add', '--string-id', String(welcomeStringId)]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'text'"); + }); + + test('requires --string-id', async () => { + const result = await ctx.runner.run(['comment', 'add', 'Orphan comment']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--string-id' option is required"); + }); + + test('requires --language when adding an issue', async () => { + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Issue without a language', + '--string-id', + String(welcomeStringId), + '--type', + 'issue', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--language' option is required when --type=issue"); + }); + + test('rejects --issue-type on a plain comment', async () => { + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Comment with an issue type', + '--string-id', + String(welcomeStringId), + '-l', + 'uk', + '--issue-type', + 'source_mistake', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Comment should not have the --issue-type parameter'); + }); + + test('rejects an unsupported --type value', async () => { + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Bad type', + '--string-id', + String(welcomeStringId), + '-l', + 'uk', + '--type', + 'suggestion', + ]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('suggestion'); + }); + + test('rejects an unsupported --issue-type value', async () => { + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Bad issue type', + '--string-id', + String(welcomeStringId), + '-l', + 'uk', + '--type', + 'issue', + '--issue-type', + 'typo', + ]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('typo'); + }); + + test('fails to add a comment to a string that does not exist', async () => { + const result = await ctx.runner.run(['comment', 'add', 'Ghost comment', '--string-id', '1', '-l', 'uk']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Comment was not added'); + }); + + test('adds a plain comment', async () => { + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Plain comment on welcome', + '--string-id', + String(welcomeStringId), + '-l', + 'uk', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Plain comment on welcome'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds an issue with a translation_mistake type', async () => { + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Wrong translation of farewell', + '--string-id', + String(farewellStringId), + '-l', + 'uk', + '--type', + 'issue', + '--issue-type', + 'translation_mistake', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Wrong translation of farewell'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + translationMistakeId = await findCommentId(ctx, 'Wrong translation of farewell'); + }); + + test('adds an issue with a source_mistake type', async () => { + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Typo in the source of farewell', + '--string-id', + String(farewellStringId), + '-l', + 'it', + '--type', + 'issue', + '--issue-type', + 'source_mistake', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Typo in the source of farewell'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists every comment', async () => { + const result = await ctx.runner.run(['comment', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Plain comment on welcome'); + expect(result.stdout).toContain('Wrong translation of farewell'); + expect(result.stdout).toContain('Typo in the source of farewell'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists comments filtered by string id', async () => { + const result = await ctx.runner.run(['comment', 'list', '--string-id', String(welcomeStringId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Plain comment on welcome'); + expect(result.stdout).not.toContain('Wrong translation of farewell'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists only issues when filtered by type', async () => { + const result = await ctx.runner.run(['comment', 'list', '--type', 'issue']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Wrong translation of farewell'); + expect(result.stdout).toContain('Typo in the source of farewell'); + expect(result.stdout).not.toContain('Plain comment on welcome'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + // --issue-type alone must still reach the API as an issue query: the CLI infers `type=issue`, + // without which the API rejects the request with "Any of [type] must be set". + test('infers the issue type when only --issue-type is given', async () => { + const result = await ctx.runner.run(['comment', 'list', '--issue-type', 'translation_mistake']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Wrong translation of farewell'); + expect(result.stdout).not.toContain('Typo in the source of farewell'); + expect(result.stdout).not.toContain('Plain comment on welcome'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('infers the issue type when only --status is given', async () => { + const result = await ctx.runner.run(['comment', 'list', '--status', 'unresolved']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Wrong translation of farewell'); + expect(result.stdout).toContain('Typo in the source of farewell'); + expect(result.stdout).not.toContain('Plain comment on welcome'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects an unsupported --status value', async () => { + const result = await ctx.runner.run(['comment', 'list', '--status', 'closed']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('closed'); + }); + + test('lists comments with the verbose view', async () => { + const result = await ctx.runner.run(['comment', 'list', '--type', 'issue', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('translation_mistake'); + expect(result.stdout).toContain('unresolved'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists comments as structured data', async () => { + const comments = await runJson<{ id: number; text: string }[]>(ctx, ['comment', 'list']); + + expect(comments.map((comment) => comment.text).sort()).toEqual([ + 'Plain comment on welcome', + 'Typo in the source of farewell', + 'Wrong translation of farewell', + ]); + }); + + test('lists comment ids only in the plain output', async () => { + const result = await ctx.runner.run(['comment', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ids = result.stdout.trim().split('\n').filter(Boolean); + + expect(ids).toHaveLength(3); + expect(ids).toContain(String(translationMistakeId)); + }); + + test('rejects a non-numeric comment id on resolve', async () => { + const result = await ctx.runner.run(['comment', 'resolve', 'abc']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Comment id must be numeric'); + }); + + test('fails to resolve a comment that does not exist', async () => { + const result = await ctx.runner.run(['comment', 'resolve', '1']); + + expect(result.exitCode).toBe(102); + expect(result.stderr).toContain('Comment #1 was not resolved'); + }); + + test('resolves a string issue', async () => { + const result = await ctx.runner.run(['comment', 'resolve', String(translationMistakeId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('has been successfully resolved'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists the resolved issue under the resolved status', async () => { + const result = await ctx.runner.run(['comment', 'list', '--status', 'resolved']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Wrong translation of farewell'); + expect(result.stdout).not.toContain('Typo in the source of farewell'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects empty comment text', async () => { + const result = await ctx.runner.run(['comment', 'add', '']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('String comment text is required'); + }); +}); diff --git a/tests/e2e/suites/config-file-options.test.ts b/tests/e2e/suites/config-file-options.test.ts new file mode 100644 index 000000000..22d704c70 --- /dev/null +++ b/tests/e2e/suites/config-file-options.test.ts @@ -0,0 +1,115 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers the per-file config keys that reach the API as a file's `exportOptions` / `importOptions` + * (`lib/upload/fileOptions.ts`). None of them had a fixture anywhere: they change nothing locally + * and nothing in the command's output, so the only way to see them is to read the uploaded file + * back. + * + * They are extension-gated, which is why one config carries three file groups - `escape_quotes` and + * `escape_special_characters` apply to `.properties` alone, `export_quotes` to `.js` alone, and the + * parser options to `.xml`. A key set on the wrong extension is silently dropped, so the grouping + * is the point rather than an accident of the fixture. + */ +describe('config file options', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + // Two targets so `export_languages: [uk]` has something to narrow - with one, its test would + // pass whether or not the key was read. + ctx = await setupSuite('config-file-options', { targetLanguageIds: ['uk', 'it'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + /** The file the project holds for a source path, with the options the upload attached to it. */ + async function projectFile(path: string) { + const response = await ctx.client.sourceFilesApi.withFetchAll().listProjectFiles(ctx.project.id); + const match = response.data.find((entry) => entry.data.path === path); + + if (!match) { + throw new Error(`File '${path}' not found via the API`); + } + + return match.data as unknown as { + exportOptions?: Record; + importOptions?: Record; + }; + } + + test('uploads every source group', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + }); + + test('sends the .properties escape options', async () => { + const file = await projectFile('/sources/messages.properties'); + + expect(file.exportOptions).toMatchObject({ escapeQuotes: 3, escapeSpecialCharacters: 0 }); + }); + + test('sends the .js export quotes', async () => { + const file = await projectFile('/sources/script.js'); + + expect(file.exportOptions).toMatchObject({ exportQuotes: 'double' }); + }); + + test('sends the .xml parser options', async () => { + // The fixture is a generic `` with an explicit `type: xml`, not a `` file: + // Crowdin types that as Android XML, where these options do not apply, and stores only + // contentSegmentation - silently, with the other three dropped and no error anywhere. + const file = await projectFile('/sources/strings.xml'); + + expect(file.importOptions).toMatchObject({ + contentSegmentation: false, + translateContent: false, + translateAttributes: false, + translatableElements: ['/catalog/item'], + }); + }); + + test('attaches the labels the config declares to every uploaded string', async () => { + await switchConfig(ctx, 'labels-and-languages'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + // The config-side counterpart of `upload sources --label`. + const labels = await ctx.client.labelsApi.withFetchAll().listLabels(ctx.project.id); + const configured = labels.data.find((entry) => entry.data.title === 'from-config'); + + expect(configured).toBeDefined(); + + const file = await projectFile('/sources/labelled.json'); + const strings = await ctx.client.sourceStringsApi + .withFetchAll() + .listProjectStrings(ctx.project.id, { fileId: (file as unknown as { id: number }).id }); + + expect(strings.data.length).toBeGreaterThan(0); + expect(strings.data.every((entry) => entry.data.labelIds?.includes(configured?.data.id as number))).toBe(true); + }); + + test('narrows the download to the languages export_languages names', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + // The project targets uk and it; the config names uk alone, so it must not appear. + expect(result.stdout.split('\n').filter(Boolean)).toEqual(['translations/uk/labelled.json']); + }); + + test('treats a file as multilingual without a scheme', async () => { + await switchConfig(ctx, 'multilingual'); + + // `multilingual: true` alone makes isMultilingualFile true, so the pattern + // may carry no language placeholder; every other multilingual fixture gets there via `scheme:`. + const result = await ctx.runner.run(['config', 'translations', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout.split('\n').filter(Boolean)).toEqual(['translations/all.json']); + }); +}); diff --git a/tests/e2e/suites/config.test.ts b/tests/e2e/suites/config.test.ts new file mode 100644 index 000000000..b9e376d2b --- /dev/null +++ b/tests/e2e/suites/config.test.ts @@ -0,0 +1,248 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { decode } from '@toon-format/toon'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `config sources` / `config translations` / `config lint` + * (`cli/commands/config/ConfigCommand.ts`): `lint`'s own two checks (the source-pattern and + * languages-mapping validations, which no other command runs), the gate where a machine `--output` + * outranks `--tree`, and every structured rendering of the listings. + * + * The alt-configs exist because those checks need a config built to *fail*, which a suite's default + * config can't also be. + * + * Not reachable from a single account, so not attempted here: the manager-role probe in + * `listTranslationsAction` (text warns and exits 0, a machine format prints an error record and + * throws Forbidden/103) needs a project the token can read but not manage, and the in-context + * pseudo-language branch needs an in-context-enabled project. + */ +const TARGET_LANGUAGES = ['it', 'uk']; + +/** Every source the default config's two groups match, as `config sources` lists them. */ +const SOURCE_PATHS = ['sources/main/app.xml', 'sources/main/nested/deep.xml', 'sources/other/lib.xml']; + +describe('config', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('config', { targetLanguageIds: TARGET_LANGUAGES }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + function lines(stdout: string): string[] { + return stdout.split('\n').filter((line) => line.length > 0); + } + + /** json writes one diagnostic record per line; the count is what proves nothing double-prints. */ + function structuredDiagnostics(stderr: string): { level: string; message: string; code?: number }[] { + return lines(stderr.trim()).map((line) => JSON.parse(line)); + } + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['config']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('validate configuration'); + expect(result.stdout).toContain('sources'); + expect(result.stdout).toContain('translations'); + expect(result.stdout).toContain('lint'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['config', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('lists the matched source files', async () => { + const result = await ctx.runner.run(['config', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('runs a command supplied by an @arg-file', async () => { + await Bun.write( + join(ctx.workspace, 'args.txt'), + '# the whole command lives here\nconfig sources\n--output plain\n', + ); + + const result = await ctx.runner.run(['@args.txt']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(lines(result.stdout).sort()).toEqual(SOURCE_PATHS); + }); + + test('keeps an @arg-file that does not exist as a literal argument', async () => { + // An unreadable @-file is not an error; the token passes through as-is, so the failure + // comes from commander not recognising it rather than from the expansion. + const result = await ctx.runner.run(['@no-such-args.txt']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command '@no-such-args.txt'"); + }); + + test('lists bare source paths with --output plain', async () => { + const result = await ctx.runner.run(['config', 'sources', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(lines(result.stdout).sort()).toEqual(SOURCE_PATHS); + }); + + test('serializes the source paths as a json list of strings', async () => { + const paths = await runJson(ctx, ['config', 'sources']); + + // pathView declares no keys and its items are the paths themselves, so the document is a list of + // bare strings rather than of objects. + expect(paths).toEqual(SOURCE_PATHS); + }); + + test('carries the same source list in the toon output', async () => { + const result = await ctx.runner.run(['config', 'sources', '--output', 'toon']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(decode(result.stdout)).toEqual(SOURCE_PATHS); + }); + + test('renders the sources as a tree', async () => { + const result = await ctx.runner.run(['config', 'sources', '--tree']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('╰─ '); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lets a machine --output outrank --tree for sources', async () => { + const result = await ctx.runner.run(['config', 'sources', '--tree', '--output', 'json']); + + expect(result).toMatchObject({ exitCode: 0 }); + // The tree is an interactive rendering; a machine format is a parseable contract and wins, so + // the glyphs must not appear and the document has to stay the same list. + expect(result.stdout).not.toContain('╰─'); + expect(JSON.parse(result.stdout)).toEqual(SOURCE_PATHS); + }); + + test('lists the translation files the config resolves to', async () => { + expect(await runJson(ctx, ['config', 'translations'])).toEqual([ + 'translations/it/app.xml', + 'translations/it/deep.xml', + 'translations/it/lib.xml', + 'translations/uk/app.xml', + 'translations/uk/deep.xml', + 'translations/uk/lib.xml', + ]); + }); + + test('lets a machine --output outrank --tree for translations', async () => { + const result = await ctx.runner.run(['config', 'translations', '--tree', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).not.toContain('╰─'); + expect(lines(result.stdout)).toHaveLength(SOURCE_PATHS.length * TARGET_LANGUAGES.length); + }); + + test('accepts a valid configuration', async () => { + const result = await ctx.runner.run(['config', 'lint']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Your configuration file looks good'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('accepts a languages_mapping whose keys are real language codes', async () => { + await switchConfig(ctx, 'good-language-mapping'); + + const result = await ctx.runner.run(['config', 'lint']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Your configuration file looks good'); + }); + + test('rejects a languages_mapping key that is not a Crowdin language code', async () => { + await switchConfig(ctx, 'bad-language-mapping'); + + const result = await ctx.runner.run(['config', 'lint']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('The mapping format is the following: crowdin_language_code: code_you_use'); + }); + + test('rejects a source pattern that matches nothing on disk', async () => { + await switchConfig(ctx, 'no-source-match'); + + const result = await ctx.runner.run(['config', 'lint']); + + expect(result.exitCode).toBe(2); + // `checkSourceFilesExist` runs nowhere else - no other command fails on a pattern matching zero + // files, they just upload nothing. + expect(result.stderr).toContain("No source files found for '/sources/nothing-here/*.xml' pattern"); + }); + + test('reports an empty listing rather than prose in a machine format', async () => { + const text = await ctx.runner.run(['config', 'sources']); + + expect(text).toMatchObject({ exitCode: 0 }); + expect(text.stdout).toContain('No source files found'); + + expect(await runJson(ctx, ['config', 'sources'])).toEqual([]); + }); + + test('reports a lint failure as one structured record carrying the exit code', async () => { + const result = await ctx.runner.run(['config', 'lint', '--output', 'json']); + + expect(result.exitCode).toBe(2); + + const records = structuredDiagnostics(result.stderr); + + // Exactly one: lintAction stays silent in a structured format so the top-level handler, the only + // place that knows the exit code, writes the record instead of duplicating it. + expect(records).toHaveLength(1); + expect(records[0]?.level).toBe('error'); + expect(records[0]?.message).toContain('No source files found'); + expect(records[0]?.code).toBe(2); + expect(result.stdout.trim()).toBe(''); + }); + + test('reports a spinner-wrapped failure as one record carrying the exit code too', async () => { + // This failure comes from `withSpinner`, which marks it reported; the record must still carry `code`. + const result = await ctx.runner.run(['config', 'sources', '--project-id', '999999999', '--output', 'json']); + + expect(result.exitCode).toBe(102); + + const records = structuredDiagnostics(result.stderr); + + expect(records).toHaveLength(1); + expect(records[0]?.message).toContain('Not Found'); + expect(records[0]?.code).toBe(102); + }); + + test('reports a missing configuration file as not found', async () => { + const result = await ctx.runner.run(['config', 'lint', '--config', 'no-such-config.yml'], { noConfig: true }); + + // A missing file is NotFound (102); invalid content is Validation (2). + expect(result.exitCode).toBe(102); + expect(result.stderr).toContain('no-such-config.yml'); + }); + + test('prints a stack trace instead of the one-line message with --debug', async () => { + // Hidden global flag. The config is still the no-source-match one from + // the tests above, so the run fails the same way - only the rendering differs. + const plain = await ctx.runner.run(['config', 'lint']); + const debug = await ctx.runner.run(['config', 'lint', '--debug']); + + expect(plain.exitCode).toBe(2); + expect(debug.exitCode).toBe(2); + + expect(plain.stderr).not.toContain(' at '); + expect(debug.stderr).toContain(' at '); + // The stack carries the message on its first line, so --debug adds frames rather than + // replacing what the plain run said. + expect(debug.stderr).toContain('No source files found'); + }); +}); diff --git a/tests/e2e/suites/context.test.ts b/tests/e2e/suites/context.test.ts new file mode 100644 index 000000000..04b6fa26e --- /dev/null +++ b/tests/e2e/suites/context.test.ts @@ -0,0 +1,394 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { findStringId } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `context download` / `upload` / `reset` / `status` + * (`cli/commands/context/ContextCommand.ts`). + * + * No AI service is involved: the "AI context" is just a marker-delimited section of a string's + * context field (`cli/utils/aiContext.ts`), so the suite writes the `ai_context` values into the + * downloaded JSONL itself and walks the full round trip. The invariant every step guards is the + * split between the two sections - manual context must survive an upload and a reset. + */ +const MANUAL_CONTEXT = 'Shown on the login screen'; +const AI_CONTEXT_PREFIX = 'Generated context for '; + +interface ContextRecord { + id: number; + key: string; + text: string; + file: string; + context: string; + ai_context: string; +} + +interface ContextStats { + total: number; + withAi: number; + withAiPercentage: string; + withoutAi: number; + withManual: number; +} + +describe('context', () => { + let ctx: SuiteContext; + let welcomeStringId: number; + let logoutStringId: number; + let checkoutStringId: number; + + beforeAll(async () => { + ctx = await setupSuite('context'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + function workspacePath(relativePath: string): string { + return join(ctx.workspace, relativePath); + } + + async function readRecords(relativePath: string): Promise { + const content = await Bun.file(workspacePath(relativePath)).text(); + + return content + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => JSON.parse(line) as ContextRecord); + } + + async function writeRecords(relativePath: string, records: ContextRecord[]): Promise { + await Bun.write(workspacePath(relativePath), records.map((record) => JSON.stringify(record)).join('\n')); + } + + async function readStats(args: string[] = []): Promise { + return runJson(ctx, ['context', 'status', ...args]); + } + + /** The status title carries the project id, which is new on every run. */ + function maskProjectId(output: string): string { + return output.replace(/\(ID: \d+\)/g, '(ID: )'); + } + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['context']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage strings context'); + expect(result.stdout).toContain('download'); + expect(result.stdout).toContain('upload'); + expect(result.stdout).toContain('reset'); + expect(result.stdout).toContain('status'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['context', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('uploads sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'app.xml'"); + expect(result.stdout).toContain("File 'web.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + welcomeStringId = await findStringId(ctx, 'Welcome aboard'); + logoutStringId = await findStringId(ctx, 'Log out'); + checkoutStringId = await findStringId(ctx, 'Proceed to checkout'); + }); + + // Crowdin derives a context of its own for every string it imports from an XML resource, so the + // project is never context-free. The suite sets a known baseline instead: one manual context, two + // strings with none, no AI context anywhere. + test('seeds a known baseline context', async () => { + for (const [id, context] of [ + [welcomeStringId, MANUAL_CONTEXT], + [logoutStringId, ''], + [checkoutStringId, ''], + ] as const) { + const edit = await ctx.runner.run(['string', 'edit', String(id), '--context', context]); + + expect(edit).toMatchObject({ exitCode: 0 }); + } + + const stats = await readStats(); + + expect(stats.total).toBe(3); + expect(stats.withAi).toBe(0); + expect(stats.withManual).toBe(1); + expect(stats.withAiPercentage).toBe('0.00'); + }); + + test('reports the coverage as a table', async () => { + const result = await ctx.runner.run(['context', 'status']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Context Status for Project'); + expect(maskProjectId(normalize(result.stdout))).toMatchSnapshot(); + }); + + test('rejects an unsupported --status value', async () => { + const result = await ctx.runner.run(['context', 'download', '--status', 'partial']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--status' parameter has an invalid value"); + }); + + test('rejects a malformed --since value', async () => { + const result = await ctx.runner.run(['context', 'status', '--since', '2026/01/01']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--since' parameter should be in 'YYYY-MM-DD' format"); + }); + + test('rejects a calendar-invalid --since date', async () => { + const result = await ctx.runner.run(['context', 'status', '--since', '2026-02-30']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--since' parameter should be in 'YYYY-MM-DD' format"); + }); + + test('downloads every string to the default context file', async () => { + const result = await ctx.runner.run(['context', 'download']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Downloaded 3 strings'); + expect(result.stdout).toContain("'crowdin-context.jsonl' saved successfully"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const records = await readRecords('crowdin-context.jsonl'); + + expect(records).toHaveLength(3); + expect(records.map((record) => record.key).sort()).toEqual(['checkout', 'logout', 'welcome']); + expect(records.map((record) => record.file).sort()).toEqual(['/app.xml', '/app.xml', '/web.xml']); + expect(records.every((record) => record.ai_context === '')).toBe(true); + + const welcome = records.find((record) => record.id === welcomeStringId); + + expect(welcome?.context).toBe(MANUAL_CONTEXT); + }); + + test('downloads only strings without any context under --status empty', async () => { + const result = await ctx.runner.run(['context', 'download', '--status', 'empty', '--to', 'empty.jsonl']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Downloaded 2 strings'); + + const records = await readRecords('empty.jsonl'); + + expect(records.map((record) => record.key).sort()).toEqual(['checkout', 'logout']); + }); + + test('downloads only manually annotated strings under --status manual', async () => { + const result = await ctx.runner.run(['context', 'download', '--status', 'manual', '--to', 'manual.jsonl']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Downloaded 1 strings'); + + const records = await readRecords('manual.jsonl'); + + expect(records.map((record) => record.key)).toEqual(['welcome']); + }); + + test('writes nothing when --status ai matches no string', async () => { + const result = await ctx.runner.run(['context', 'download', '--status', 'ai', '--to', 'ai.jsonl']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain('No strings found'); + expect(await Bun.file(workspacePath('ai.jsonl')).exists()).toBe(false); + }); + + test('downloads only the strings of a filtered file', async () => { + const result = await ctx.runner.run(['context', 'download', '--file', '/app.xml', '--to', 'app.jsonl']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Downloaded 2 strings'); + + const records = await readRecords('app.jsonl'); + + expect(records.map((record) => record.key).sort()).toEqual(['logout', 'welcome']); + }); + + // The download rewrites --to wholesale, so a target holding anything else has to stop the run. + test('refuses to overwrite a file that is not a context file', async () => { + const result = await ctx.runner.run(['context', 'download', '--to', 'sources/app.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('is not a context file'); + expect(await Bun.file(workspacePath('sources/app.xml')).text()).toContain('Welcome aboard'); + }); + + test('fails to upload a context file that does not exist', async () => { + const result = await ctx.runner.run(['context', 'upload', '--from', 'missing.jsonl']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File 'missing.jsonl' not found in the Crowdin project"); + }); + + test('uploads nothing while every ai_context is empty', async () => { + const result = await ctx.runner.run(['context', 'upload']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("No strings with AI context found in 'crowdin-context.jsonl'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports the pending changes under --dryrun without applying them', async () => { + const records = await readRecords('crowdin-context.jsonl'); + + await writeRecords( + 'crowdin-context.jsonl', + records.map((record) => ({ ...record, ai_context: `${AI_CONTEXT_PREFIX}${record.key}` })), + ); + + const result = await ctx.runner.run(['context', 'upload', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('would be uploaded'); + + // Every record's context spans several lines (the AI section sits below the manual one behind + // its markers), so the three listings interleave into an order `normalize` cannot sort - this + // is the one output in the suite that can't be snapshotted. + for (const key of ['welcome', 'logout', 'checkout']) { + expect(result.stdout).toContain(`${AI_CONTEXT_PREFIX}${key}`); + } + + const stats = await readStats(); + + expect(stats.withAi).toBe(0); + }); + + test('uploads the AI context', async () => { + const result = await ctx.runner.run(['context', 'upload']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Updated strings 3/3'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const stats = await readStats(); + + expect(stats.withAi).toBe(3); + expect(stats.withoutAi).toBe(0); + expect(stats.withAiPercentage).toBe('100.00'); + }); + + // The upload writes both sections into one context field; the manual half has to come back out + // of it untouched, which is the whole point of the marker split. + test('keeps the manual context alongside the uploaded AI context', async () => { + const result = await ctx.runner.run(['context', 'download', '--to', 'round-trip.jsonl']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const records = await readRecords('round-trip.jsonl'); + const welcome = records.find((record) => record.id === welcomeStringId); + + expect(welcome?.context).toBe(MANUAL_CONTEXT); + expect(welcome?.ai_context).toBe(`${AI_CONTEXT_PREFIX}welcome`); + }); + + test('rejects a context file with an unparsable line', async () => { + const records = await readRecords('crowdin-context.jsonl'); + + await Bun.write( + workspacePath('broken.jsonl'), + [JSON.stringify(records[0]), 'not a json line', JSON.stringify(records[1])].join('\n'), + ); + + const result = await ctx.runner.run(['context', 'upload', '--from', 'broken.jsonl']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('contains an invalid record at line 2'); + }); + + test('breaks the coverage down per file', async () => { + const result = await ctx.runner.run(['context', 'status', '--by-file']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('/app.xml'); + expect(result.stdout).toContain('/web.xml'); + expect(maskProjectId(normalize(result.stdout))).toMatchSnapshot(); + }); + + test('breaks the coverage down per file in the plain output', async () => { + const result = await ctx.runner.run(['context', 'status', '--by-file', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('With AI context:'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('requires --all when resetting without any filter', async () => { + const result = await ctx.runner.run(['context', 'reset']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--all' parameter should be specified explicitly if no other filter"); + }); + + test('reports the strings a filtered reset would clear under --dryrun', async () => { + const result = await ctx.runner.run(['context', 'reset', '--file', '/web.xml', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('would be updated'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const stats = await readStats(); + + expect(stats.withAi).toBe(3); + }); + + test('clears the AI context of a filtered file only', async () => { + const result = await ctx.runner.run(['context', 'reset', '--file', '/web.xml']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Updated strings 1/1'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const stats = await readStats(); + + expect(stats.withAi).toBe(2); + }); + + test('clears every remaining AI context under --all, keeping the manual context', async () => { + const result = await ctx.runner.run(['context', 'reset', '--all']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Updated strings 2/2'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const stats = await readStats(); + + expect(stats.withAi).toBe(0); + expect(stats.withManual).toBe(1); + }); + + test('reports nothing to reset once no AI context is left', async () => { + const result = await ctx.runner.run(['context', 'reset', '--all']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain('No strings found'); + }); + + test('skips a record with an empty ai_context unless --overwrite is given', async () => { + const records = await readRecords('crowdin-context.jsonl'); + + // One record left empty, the rest given an AI context. Without --overwrite the empty one is + // filtered out; with it, it is kept so its AI section can be removed. + await writeRecords( + 'crowdin-context.jsonl', + records.map((record, index) => ({ + ...record, + ai_context: index === 0 ? '' : `${AI_CONTEXT_PREFIX}${record.key}`, + })), + ); + + expect(await runJson(ctx, ['context', 'upload', '--dryrun'])).toHaveLength(records.length - 1); + expect(await runJson(ctx, ['context', 'upload', '--dryrun', '--overwrite'])).toHaveLength(records.length); + }); +}); diff --git a/tests/e2e/suites/custom-language.test.ts b/tests/e2e/suites/custom-language.test.ts new file mode 100644 index 000000000..5afceedfb --- /dev/null +++ b/tests/e2e/suites/custom-language.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { type Client, CrowdinValidationError, type LanguagesModel } from '@crowdin/crowdin-api-client'; +import { resolveEnv } from '../helpers/env.ts'; +import { expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { createApiClient } from '../helpers/project.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +const DOTHRAKI_LANGUAGE: LanguagesModel.AddLanguageRequest = { + name: 'Dothraki', + code: 'dtk', + localeCode: 'dtk', + threeLettersCode: 'dtk', + textDirection: 'ltr', + pluralCategoryNames: ['one', 'other'], +}; + +/** + * Adds the custom 'Dothraki' language to the whole Crowdin account — this is account-level + * (`POST /languages`), not project-scoped, and must exist *before* a project can be created with + * it as a target language. + * + * The language outlives the project, so a second run must tolerate "code already taken"; every + * other error still propagates. + */ +async function ensureDothrakiLanguage(client: Client): Promise { + try { + await client.languagesApi.addCustomLanguage(DOTHRAKI_LANGUAGE); + } catch (error) { + const alreadyExists = + error instanceof CrowdinValidationError && + error.validationCodes.some((entry) => entry.codes.includes('languageFieldUniqueInvalid')); + + if (!alreadyExists) { + throw error; + } + } +} + +describe('custom language', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + const env = resolveEnv(); + if (!env.token) { + throw new Error('CROWDIN_E2E_TOKEN is not set. E2E suites require a dedicated test-account token.'); + } + + await ensureDothrakiLanguage(createApiClient(env)); + + ctx = await setupSuite('custom-language', { targetLanguageIds: ['uk', 'dtk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for both the custom and standard target language', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/dtk/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/dtk/2_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for both the custom and standard target language', async () => { + // The upload fixtures sit at the download's own paths, so clear them first - otherwise the + // comparison is against the local copy, not what the server returned. + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected', + 'dtk/1_android.xml', + 'dtk/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); +}); diff --git a/tests/e2e/suites/custom-segmentation.test.ts b/tests/e2e/suites/custom-segmentation.test.ts new file mode 100644 index 000000000..9c0aeb500 --- /dev/null +++ b/tests/e2e/suites/custom-segmentation.test.ts @@ -0,0 +1,144 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Overwrites the workspace's `rules/sample.srx.xml` — the file the fixture's `crowdin.yml` + * `custom_segmentation` points to — with one of the static `rules/.srx.xml` fixtures. + */ +async function useSrxRules(ctx: SuiteContext, variant: string): Promise { + const content = await Bun.file(join(ctx.workspace, 'rules', `${variant}.srx.xml`)).text(); + await Bun.write(join(ctx.workspace, 'rules', 'sample.srx.xml'), content); +} + +describe('custom segmentation', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('custom-segmentation', { targetLanguageIds: ['it', 'uk'] }); + + // A distractor: project-level file-format settings that contradict the + // CLI's own config. The upload reads only the config, so these must change nothing. + await ctx.client.projectsGroupsApi.addProjectFileFormatSettings(ctx.project.id, { + format: 'docx', + settings: { + cleanTagsAggressively: false, + contentSegmentation: true, + importHiddenSlides: false, + importNotes: false, + translateHiddenRowsAndColumns: false, + translateHiddenText: false, + translateHyperlinkUrls: false, + }, + }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('rejects an invalid SRX file for every source file', async () => { + await useSrxRules(ctx, 'invalid'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result.exitCode).toBe(1); + // The "sources" directory is created before the per-file srxStorageId is validated by the API, + // so it still succeeds even though both file creations below fail. + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stderr).toContain( + "Failed to create file 'sample.docx'. Key: importOptions. Message: Invalid SRX specified. XML validation module returned: attributes construct error", + ); + expect(result.stderr).toContain( + "Failed to create file 'strings.xml'. Key: importOptions. Message: Invalid SRX specified. XML validation module returned: attributes construct error", + ); + expect(result.stderr).toContain('Current execution finished with errors'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects an SRX file with an invalid regular expression', async () => { + await useSrxRules(ctx, 'invalid-regexp'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result.exitCode).toBe(1); + // The "sources" directory survived the previous failed attempt, so it is not created again. + expect(result.stdout).not.toContain("Directory 'sources'"); + // The quoting around the regex is the API's own and has changed once already (backticks to + // double quotes), so it is not pinned - the message up to it, and the regex itself, are. + for (const file of ['sample.docx', 'strings.xml']) { + expect(result.stderr).toContain( + `Failed to create file '${file}'. Key: importOptions. Message: Invalid SRX specified. Invalid regular expression`, + ); + } + + expect(result.stderr).toContain('/^.*[$/'); + expect(result.stderr).toContain('Current execution finished with errors'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources once a valid SRX file is supplied', async () => { + await useSrxRules(ctx, 'valid'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/sample.docx'"); + expect(result.stdout).toContain("File 'sources/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates sources after the SRX rules change', async () => { + await useSrxRules(ctx, 'sampleV2'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/sample.docx'"); + expect(result.stdout).toContain("File 'sources/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources to a dest path and reflects the earlier SRX rules update on the original docx', async () => { + await switchConfig(ctx, 'crowdin-rev2'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'Folder'"); + // Success messages echo the PROJECT path, so the `dest` remapping shows up here directly: + // `/sources/sample.docx` is uploaded as `Folder/sample.docx`. + expect(result.stdout).toContain("File 'Folder/sample.docx'"); + expect(result.stdout).toContain("File 'Folder/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // Changing `dest` does not move the file: the lookup misses, so a second file is created at the + // new path with no translations while the original stays behind holding all of its. + // `--delete-obsolete` deletes the orphan rather than moving it. + const files = await ctx.client.sourceFilesApi.listProjectFiles(ctx.project.id, { recursion: '1' }); + const docxPaths = files.data + .filter((file) => file.data.name === 'sample.docx') + .map((file) => file.data.path) + .sort(); + + expect(docxPaths).toEqual(['/Folder/sample.docx', '/sources/sample.docx']); + + // The previous test's sampleV2 rules (break="no" on sentence-ending punctuation) took effect on the + // original file: the two sentences merge into one segment instead of v1's two. Checked at the old + // path, which this test's upload never touches. + const sourceDocx = files.data.find((file) => file.data.path === '/sources/sample.docx'); + expect(sourceDocx).toBeDefined(); + + const strings = await ctx.client.sourceStringsApi.listProjectStrings(ctx.project.id, { + fileId: sourceDocx?.data.id, + }); + // The fixture's `` runs separate the sentences with a NON-BREAKING space (U+00A0), which is what + // Word writes after an abbreviation - spelled as an escape here because it is indistinguishable from a + // plain space on screen, and a mismatch between the two renders as two identical-looking strings in the + // failure diff. It is also why sampleV2's `break="no"` rule has something to bind here at all. + expect(strings.data.map((entry) => entry.data.text)).toEqual([ + 'The U.K.\u00A0Prime Minister, Mr. Blair, was seen out with his family today.\u00A0Boris Johnson also was there.', + ]); + }); +}); diff --git a/tests/e2e/suites/delete-obsolete.test.ts b/tests/e2e/suites/delete-obsolete.test.ts new file mode 100644 index 000000000..ba1f50e21 --- /dev/null +++ b/tests/e2e/suites/delete-obsolete.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** Strips a leading slash so paths compare equal regardless of which form the API returns. */ +function stripLeadingSlash(path: string): string { + return path.startsWith('/') ? path.slice(1) : path; +} + +async function projectFilePaths(ctx: SuiteContext): Promise { + const files = await ctx.client.sourceFilesApi.listProjectFiles(ctx.project.id); + return files.data.map((file) => stripLeadingSlash(file.data.path)).sort(); +} + +async function projectDirectoryPaths(ctx: SuiteContext): Promise { + const directories = await ctx.client.sourceFilesApi.listProjectDirectories(ctx.project.id); + return directories.data.map((directory) => stripLeadingSlash(directory.data.path)).sort(); +} + +describe('delete obsolete', () => { + let ctx: SuiteContext; + + // Each run passes `--base-path ` to walk the fixture revisions; the two steps that also + // change a `dest` swap the whole config. + beforeAll(async () => { + ctx = await setupSuite('delete-obsolete', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads all sources', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--base-path', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'destination'"); + expect(result.stdout).toContain("Directory 'lang'"); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(result.stdout).toContain("File '3_android.xml'"); + expect(result.stdout).toContain("File 'destination/1_simple.csv'"); + expect(result.stdout).toContain("File 'lang/4_android.xml'"); + expect(result.stdout).toContain("File 'lang/en-US.json'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual( + [ + '1_android.xml', + '2_android.xml', + '3_android.xml', + 'destination/1_simple.csv', + 'lang/4_android.xml', + 'lang/en-US.json', + ].sort(), + ); + }); + + test('deletes nothing for real with --delete-obsolete --dryrun', async () => { + const beforeFiles = await projectFilePaths(ctx); + + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--base-path', + 'sources_rev2', + '--delete-obsolete', + '--dryrun', + ]); + + // sources_rev2/ has no CSV, so the fixture's '/*.csv' group matches nothing and flags the run. + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("No sources found for '/*.csv' pattern"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // sources_rev2/ drops 2_android.xml and 1_simple.csv - a real run would delete their remote + // counterparts, but --dryrun must leave the project untouched. + expect(await projectFilePaths(ctx)).toEqual(beforeFiles); + }); + + test('deletes obsolete files and directories for real with --delete-obsolete', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--base-path', 'sources_rev3', '--delete-obsolete']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // sources_rev3/ only keeps 1_android.xml and 1_simple.csv - everything else that was still + // present on the server (because the previous step was a dry run) must be gone for real now, + // including the now-empty 'lang' directory. + expect(await projectFilePaths(ctx)).toEqual(['1_android.xml', 'destination/1_simple.csv'].sort()); + expect(await projectDirectoryPaths(ctx)).not.toContain('lang'); + }); + + test('deletes an obsolete file whose remaining sibling now has a dest', async () => { + await switchConfig(ctx, 'crowdin-rev4'); + + const result = await ctx.runner.run(['upload', 'sources', '--base-path', 'sources_rev4', '--delete-obsolete']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // sources_rev4/ has only 1_android.xml, and the new config remaps every file under + // 'destination/' - 1_simple.csv (with no local counterpart anymore) becomes obsolete. + const paths = await projectFilePaths(ctx); + expect(paths).not.toContain('destination/1_simple.csv'); + }); + + test('nothing to delete when the dest remap makes local and remote paths coincide (dryrun)', async () => { + await switchConfig(ctx, 'crowdin-rev5'); + await ctx.runner.run(['upload', 'sources', '--base-path', 'sources_rev5']); + const beforeFiles = await projectFilePaths(ctx); + + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--base-path', + 'sources_rev5', + '--delete-obsolete', + '--dryrun', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // sources_rev5/ (1_android.xml, 2_android.xml) already matches the current dest mapping + // 1:1, so there is nothing obsolete to report even in a real run - a dry run must be a no-op. + expect(await projectFilePaths(ctx)).toEqual(beforeFiles); + }); + + test('reports the steady state once local and remote paths already coincide', async () => { + await ctx.runner.run(['upload', 'sources', '--base-path', 'sources_rev5']); + const beforeFiles = await projectFilePaths(ctx); + + const result = await ctx.runner.run(['upload', 'sources', '--base-path', 'sources_rev5', '--delete-obsolete']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // Nothing obsolete remains, so a real --delete-obsolete run changes nothing on the server. + expect(await projectFilePaths(ctx)).toEqual(beforeFiles); + }); +}); diff --git a/tests/e2e/suites/dest.test.ts b/tests/e2e/suites/dest.test.ts new file mode 100644 index 000000000..9147db654 --- /dev/null +++ b/tests/e2e/suites/dest.test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { captureAndClear, expectFilesExist, expectRestored } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `dest:` pattern shapes across + * several file groups, upload/download of their translations, the same pair against a branch, a + * configless single-file upload with `--dest`, and a config-validation negative case. + */ +describe('dest', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('dest', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + async function projectPaths(branchName?: string): Promise { + let branchId: number | undefined; + + if (branchName) { + const branches = await ctx.client.sourceFilesApi.withFetchAll().listProjectBranches(ctx.project.id, { + name: branchName, + }); + + branchId = branches.data.find((entry) => entry.data.name === branchName)?.data.id; + + if (branchId === undefined) { + throw new Error(`Branch '${branchName}' not found via the API`); + } + } + + // `recursion` is what reaches the files nested in directories; without it the listing stops at + // the branch (or project) root. Same call the CLI's own `loadProjectFiles` makes, including the + // branch filter, since the recursive listing spans every branch. + const files = await ctx.client.sourceFilesApi + .withFetchAll() + .listProjectFiles(ctx.project.id, { branchId, recursion: '1' }); + + return files.data + .filter((entry) => (entry.data.branchId ?? null) === (branchId ?? null)) + .map((entry) => entry.data.path) + .sort(); + } + + const DEST_PATHS = [ + '/Android.xml', + '/Folder/Android.xml', + '/Folder/Client.xml', + '/Test-destCheckFolder/xml/android/android.xml', + '/Test-destCheckFolderParallelFileProcess/xml/android.xml', + '/Test-destCheckFolderParallelFileProcess/xml/second_android.xml', + ]; + + test('uploads sources across dest-remapped file groups', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + // Success echoes the `dest`-remapped project path, not the local one. + expect(result.stdout).toContain("File 'Android.xml'"); + expect(result.stdout).toContain("Directory 'Folder'"); + expect(result.stdout).toContain("File 'Folder/Android.xml'"); + expect(result.stdout).toContain("File 'Folder/Client.xml'"); + expect(result.stdout).toContain("File 'Test-destCheckFolder/xml/android/android.xml'"); + expect(result.stdout).toContain("File 'Test-destCheckFolderParallelFileProcess/xml/android.xml'"); + expect(result.stdout).toContain("File 'Test-destCheckFolderParallelFileProcess/xml/second_android.xml'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + + // `%original_path%` is the source file's parent directory, so `destCheckFolder/android.xml` maps + // onto `Test-destCheckFolder/xml/android/android.xml` - no directory named after the file itself. + expect(await projectPaths()).toEqual(DEST_PATHS); + }); + + test('uploads translations across dest-remapped file groups', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + + // Local translation paths come from each group's `translation:` pattern alone - `dest` never + // enters into them. + for (const path of [ + 'android_it_IT.xml', + 'android_uk_UA.xml', + 'folder/android_it_IT.xml', + 'folder/android_uk_UA.xml', + 'folder/client_it_IT.xml', + 'folder/client_uk_UA.xml', + 'destCheckFolder/android_it_IT.xml', + 'destCheckFolder/android_uk_UA.xml', + 'destCheckFolderParallelFileProcess/android_it_IT.xml', + 'destCheckFolderParallelFileProcess/android_uk_UA.xml', + 'destCheckFolderParallelFileProcess/second_android_it_IT.xml', + 'destCheckFolderParallelFileProcess/second_android_uk_UA.xml', + ]) { + expect(result.stdout).toContain(`Importing translations for file '${path}'`); + expect(result.stdout).toContain(`File '${path}'`); + } + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations and matches the uploaded content for the parallel file group', async () => { + // Each path already holds an upload fixture, so clear it first - otherwise the check compares + // every file against itself and always passes. + const captured = await captureAndClear( + ctx.workspace, + 'android_it_IT.xml', + 'android_uk_UA.xml', + 'folder/android_it_IT.xml', + 'folder/android_uk_UA.xml', + 'folder/client_it_IT.xml', + 'folder/client_uk_UA.xml', + 'destCheckFolder/android_it_IT.xml', + 'destCheckFolder/android_uk_UA.xml', + 'destCheckFolderParallelFileProcess/android_it_IT.xml', + 'destCheckFolderParallelFileProcess/android_uk_UA.xml', + 'destCheckFolderParallelFileProcess/second_android_it_IT.xml', + 'destCheckFolderParallelFileProcess/second_android_uk_UA.xml', + ); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectRestored(ctx.workspace, captured); + }); + + test('uploads sources to a branch with dest remapping', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'Android.xml'"); + expect(result.stdout).toContain("Directory 'Folder'"); + expect(result.stdout).toContain("File 'Folder/Android.xml'"); + expect(result.stdout).toContain("File 'Folder/Client.xml'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectPaths('test-branch')).toEqual(DEST_PATHS.map((path) => `/test-branch${path}`)); + }); + + test('uploads translations to the branch', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android_it_IT.xml'"); + expect(result.stdout).toContain("File 'android_uk_UA.xml'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations from the branch', async () => { + const result = await ctx.runner.run(['download', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'android_it_IT.xml', 'android_uk_UA.xml'); + }); + + test('uploads a single file with an explicit --dest and no config file', async () => { + // `-s`/`-t` build a one-file config on their own, so no config file is read at all. + const result = await ctx.runner.run( + [ + 'upload', + 'sources', + '-s', + 'android.xml', + '-t', + '/translations/%two_letters_code%/%original_file_name%', + '-i', + String(ctx.project.id), + '-T', + ctx.env.token as string, + '--dest', + 'SingleDest/%file_extension%/%file_name%/%original_file_name%', + '--base-url', + 'https://api.crowdin.com', + '--no-progress', + '--no-colors', + ], + { noConfig: true }, + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'SingleDest'"); + expect(result.stdout).toContain("Directory 'SingleDest/xml'"); + expect(result.stdout).toContain("Directory 'SingleDest/xml/android'"); + expect(result.stdout).toContain("File 'SingleDest/xml/android/android.xml'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports a configuration error for dest without preserve_hierarchy', async () => { + // There is no check for a glob `source` combined with a literal `dest`, only the + // `preserve_hierarchy` one exercised here. + await switchConfig(ctx, 'crowdin-invalid'); + + const result = await ctx.runner.run(['upload', 'sources', '--no-preserve-hierarchy']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "The 'dest' parameter only works for single files with the specified 'preserve_hierarchy': true option", + ); + }); +}); diff --git a/tests/e2e/suites/distribution.test.ts b/tests/e2e/suites/distribution.test.ts new file mode 100644 index 000000000..1b4f1c332 --- /dev/null +++ b/tests/e2e/suites/distribution.test.ts @@ -0,0 +1,229 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `distribution list` / `add` / `edit` / `release` + * (`cli/commands/distribution/DistributionCommand.ts`). + * + * A distribution is defined by the bundles it exports, so the suite creates its own first, reading + * the id from `bundle add`'s `#` echo as `bundle.test.ts` does. + * + * Hashes are server-generated and differ every run, and `normalize` masks only `#123`-style ids, so + * snapshots go through `maskHash` - mirroring `bundle.test.ts`'s `maskBundleId`. + */ + +function maskHash(output: string, hash: string): string { + return output.replaceAll(hash, ''); +} + +interface ListedDistribution { + hash: string; + name: string; + exportMode: string; +} + +describe('distribution', () => { + let ctx: SuiteContext; + let bundleId: string; + let secondBundleId: string; + let hash: string; + + async function listDistributions(): Promise { + return runJson(ctx, ['distribution', 'list']); + } + + async function addBundle(name: string): Promise { + const result = await ctx.runner.run([ + 'bundle', + 'add', + name, + '--format', + 'macosx', + '--source-pattern', + '**', + '--export-pattern', + 'all.string', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + + const id = result.stdout.match(/#(\d+)/)?.[1] ?? ''; + + expect(id).not.toBe(''); + + return id; + } + + beforeAll(async () => { + ctx = await setupSuite('distribution', { targetLanguageIds: ['uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads the sources and translations the bundles export', async () => { + const sources = await ctx.runner.run(['upload', 'sources']); + + expect(sources).toMatchObject({ exitCode: 0 }); + expect(sources.stdout).toContain("File 'sources/1_android.xml'"); + + const translations = await ctx.runner.run(['upload', 'translations']); + + expect(translations).toMatchObject({ exitCode: 0 }); + expect(translations.stdout).toContain("File 'translations/uk/1_android.xml'"); + }); + + test('creates the bundles a distribution needs', async () => { + bundleId = await addBundle('DistributionBundle'); + secondBundleId = await addBundle('SecondBundle'); + + expect(bundleId).not.toBe(secondBundleId); + }); + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['distribution']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage distributions'); + expect(result.stdout).toContain('release '); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['distribution', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('reports a project with no distributions', async () => { + const result = await ctx.runner.run(['distribution', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('No distributions found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('requires a name to add', async () => { + const result = await ctx.runner.run(['distribution', 'add']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'name'"); + }); + + test('requires at least one bundle id', async () => { + const result = await ctx.runner.run(['distribution', 'add', 'D1']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Bundle IDs are required. Use --bundle-id (can be specified multiple times)'); + }); + + test('rejects a non-numeric bundle id', async () => { + const result = await ctx.runner.run(['distribution', 'add', 'D1', '--bundle-id', 'abc']); + + // toNumberArray raises a validation error, so exit 2 rather than the generic 1. + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Invalid bundle id'); + }); + + test('adds a distribution for a bundle', async () => { + const result = await ctx.runner.run(['distribution', 'add', 'D1', '--bundle-id', bundleId]); + + expect(result).toMatchObject({ exitCode: 0 }); + + const [distribution] = await listDistributions(); + + expect(distribution).toMatchObject({ name: 'D1', exportMode: 'bundle' }); + hash = (distribution as ListedDistribution).hash; + + expect(maskHash(normalize(result.stdout), hash)).toMatchSnapshot(); + }); + + test('lists the distribution with its hash and export mode', async () => { + const result = await ctx.runner.run(['distribution', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(hash); + expect(maskHash(normalize(result.stdout), hash)).toMatchSnapshot(); + }); + + test('lists the hash and name with --output plain', async () => { + const result = await ctx.runner.run(['distribution', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout.trim()).toBe(`${hash} D1`); + }); + + test('serializes hash, name and export mode in a structured format', async () => { + expect(await listDistributions()).toEqual([{ hash, name: 'D1', exportMode: 'bundle' }]); + }); + + test('requires a hash to edit', async () => { + const result = await ctx.runner.run(['distribution', 'edit']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'hash'"); + }); + + test('requires at least one parameter to edit', async () => { + const result = await ctx.runner.run(['distribution', 'edit', hash]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Specify the parameters to edit the distribution'); + }); + + test('rejects editing a hash that does not exist', async () => { + // editAction calls getByHash before patching, so an unknown hash fails before any write. + const result = await ctx.runner.run(['distribution', 'edit', 'nosuchhash', '--name', 'X']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Couldn't find distribution with the specified hash"); + }); + + test('renames a distribution', async () => { + const result = await ctx.runner.run(['distribution', 'edit', hash, '--name', 'D1 renamed']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(await listDistributions()).toEqual([{ hash, name: 'D1 renamed', exportMode: 'bundle' }]); + }); + + test('replaces the bundle list', async () => { + // editAction's other patch branch: `--bundle-id` becomes a replace on /bundleIds. + const result = await ctx.runner.run(['distribution', 'edit', hash, '--bundle-id', secondBundleId]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(await listDistributions()).toEqual([{ hash, name: 'D1 renamed', exportMode: 'bundle' }]); + }); + + test('rejects releasing a hash that does not exist', async () => { + const result = await ctx.runner.run(['distribution', 'release', 'nosuchhash']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Couldn't find distribution with the specified hash"); + }); + + test('releases the distribution', async () => { + // Last: polls a real build to completion. Whether any poll catches a percentage is a timing + // race, so only the terminal outcome is pinned. + const result = await ctx.runner.run(['distribution', 'release', hash]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`Distribution '${hash}' has been successfully released`); + expect(result.stdout).not.toContain('null%'); + }); + + test('rejects an empty distribution name on add', async () => { + const result = await ctx.runner.run(['distribution', 'add', '']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Distribution name is required'); + }); + + test.each([['edit'], ['release']])('rejects an empty distribution hash on %s', async (subcommand) => { + const result = await ctx.runner.run(['distribution', subcommand, '']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Distribution hash is required'); + }); +}); diff --git a/tests/e2e/suites/download-pseudo.test.ts b/tests/e2e/suites/download-pseudo.test.ts new file mode 100644 index 000000000..0f8e0411b --- /dev/null +++ b/tests/e2e/suites/download-pseudo.test.ts @@ -0,0 +1,246 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Every valid pseudo-localization test downloads to `translations//android.xml`, and the + * three "no character_transformation" cases plus the default-settings case all share the same + * `en` destination. Clear it before each download so a would-be failed/omitted extraction can't be + * masked by a stale file left over from an earlier test at the same path. + */ +async function clearDownloadedTranslations(ctx: SuiteContext): Promise { + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); +} + +describe('download pseudo', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + // The pseudo build's language comes from `character_transformation` alone (none -> en, asian -> + // zh-TW, european -> fr, cyrillic -> uk, arabic -> ar), but the CLI only maps archive entries for + // the project's own target languages. A non-English source language lets `en` be a real target + // too, so every transformation has somewhere to land. + ctx = await setupSuite('download-pseudo', { + sourceLanguageId: 'de', + targetLanguageIds: ['en', 'uk', 'zh-TW', 'fr', 'ar'], + }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads the single source file', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads pseudo translations with all parameters (cyrillic transformation)', async () => { + await switchConfig(ctx, 'all-params'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Building pseudo translations'); + expect(result.stdout).toContain('Downloading translations'); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations/uk', 'expected/all_params', 'android.xml'); + }); + + test('downloads pseudo translations with asian character transformation', async () => { + await switchConfig(ctx, 'asian'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/zh/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations/zh', 'expected/asian', 'android.xml'); + }); + + test('downloads pseudo translations with european character transformation', async () => { + await switchConfig(ctx, 'european'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/fr/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations/fr', 'expected/european', 'android.xml'); + }); + + test('downloads pseudo translations with arabic character transformation', async () => { + await switchConfig(ctx, 'arabic'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/ar/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations/ar', 'expected/arabic', 'android.xml'); + }); + + test('downloads pseudo translations with length correction only', async () => { + await switchConfig(ctx, 'length-correction'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/en/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations/en', 'expected/length_correction', 'android.xml'); + }); + + test('downloads pseudo translations with prefix only', async () => { + await switchConfig(ctx, 'prefix'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/en/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations/en', 'expected/prefix', 'android.xml'); + }); + + test('downloads pseudo translations with suffix only', async () => { + await switchConfig(ctx, 'suffix'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/en/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations/en', 'expected/suffix', 'android.xml'); + }); + + test('downloads pseudo translations using default settings when pseudo_localization is absent', async () => { + await switchConfig(ctx, 'no-pseudo-section'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/en/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations/en', 'expected/default', 'android.xml'); + }); + + test('rejects an unknown character_transformation value', async () => { + await switchConfig(ctx, 'invalid-enum'); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Invalid option: expected one of "asian"|"european"|"arabic"|"cyrillic"'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects a prefix of the wrong type', async () => { + await switchConfig(ctx, 'invalid-prefix-type'); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Invalid input: expected string, received number'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects a length_correction outside the -50..100 range', async () => { + await switchConfig(ctx, 'invalid-length-out-of-range'); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Too big: expected number to be <=100'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + // Every run above passes `--pseudo` alone; these pair it with the other download flags. The + // schema-failure tests leave an invalid config behind, so each of these switches first. + test('previews a pseudo download without writing anything', async () => { + await switchConfig(ctx, 'cyrillic'); + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--pseudo', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('translations/uk/android.xml'); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/android.xml')).exists()).toBe(false); + }); + + test('maps the pseudo archive against every project language, ignoring --language', async () => { + await switchConfig(ctx, 'cyrillic'); + await clearDownloadedTranslations(ctx); + + // `-l fr` narrows a normal download to French. A pseudo build has one language of its own - + // cyrillic means uk - and is mapped against every project language rather than the resolved + // set, so uk still lands despite naming a different language here. + const result = await ctx.runner.run(['download', 'translations', '--pseudo', '-l', 'fr']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/android.xml')).exists()).toBe(true); + }); + + test('builds pseudo translations for a branch', async () => { + await switchConfig(ctx, 'cyrillic'); + await clearDownloadedTranslations(ctx); + + const upload = await ctx.runner.run(['upload', 'sources', '-b', 'pseudo-branch']); + + expect(upload).toMatchObject({ exitCode: 0 }); + + // The branch id is the one field a pseudo build carries beyond the localization settings. + const result = await ctx.runner.run(['download', 'translations', '--pseudo', '-b', 'pseudo-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + }); + + test('still builds pseudo translations when the config sets export options', async () => { + await switchConfig(ctx, 'export-only-approved'); + await clearDownloadedTranslations(ctx); + + // Nothing here is approved, so a normal download of this config falls back to the source text. + const normal = await ctx.runner.run(['download', 'translations']); + + expect(normal).toMatchObject({ exitCode: 0 }); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/android.xml')).text()).toContain('first string'); + + // Also the only reachable case of the omitted report's second list: archive entries matching + // no project source, the sibling of the warning translations-not-match covers. + expect(normal.stderr).toContain('Due to missing respective sources, the following translations will be omitted:'); + + await clearDownloadedTranslations(ctx); + + // A pseudo build is a single all-files request carrying no export options, so the same config + // yields transformed text instead of the source. + const pseudo = await ctx.runner.run(['download', 'translations', '--pseudo']); + + expect(pseudo).toMatchObject({ exitCode: 0 }); + expect(pseudo.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/android.xml')).text()).not.toContain('first string'); + }); +}); diff --git a/tests/e2e/suites/download-sources.test.ts b/tests/e2e/suites/download-sources.test.ts new file mode 100644 index 000000000..54c7cfd52 --- /dev/null +++ b/tests/e2e/suites/download-sources.test.ts @@ -0,0 +1,223 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { capturedContent, expectFilesExist } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { createExtraProject, type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +// Local paths the nested source patterns resolve to (see fixtures/download-sources/config/crowdin.yml). +// `download sources` reconstructs these exact local paths from the `source` pattern regardless of the +// group's `dest` (folder_1's files are stored server-side under `root/...` but download back here). +const SOURCE_RELATIVE_PATHS = [ + 'folder_1/android.xml', + 'folder_1/f1/android.xml', + 'folder_1/f1/f2/android.xml', + 'folder_2/android_1.xml', + 'folder_2/android_2.xml', + 'folder_2/android_3.xml', + 'folder_2/android_4a.xml', +]; + +async function removeDownloadedSources(ctx: SuiteContext): Promise { + await rm(join(ctx.workspace, 'folder_1'), { recursive: true, force: true }); + await rm(join(ctx.workspace, 'folder_2'), { recursive: true, force: true }); +} + +describe('download sources', () => { + let ctx: SuiteContext; + // Captured so a later test can switch back after the no-sources config is swapped in. + let originalConfig: string; + let stringsBasedProjectId: number; + // Captured before the first download deletes the local copies; the branch upload used the same + // fixture files, so every later test compares against these bytes. + const sourceContent = new Map(); + + beforeAll(async () => { + ctx = await setupSuite('download-sources', { targetLanguageIds: ['it', 'uk'] }); + originalConfig = await Bun.file(join(ctx.workspace, 'crowdin.yml')).text(); + // File management is refused for string-based projects, and this suite's own is file-based. + stringsBasedProjectId = await createExtraProject(ctx, { suite: 'download-sources-strings', stringsBased: true }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads all nested source files to the project', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + // Captured before the assertions below, so a failure here does not cascade as 'No content was + // captured' through the rest of the suite. + for (const relativePath of SOURCE_RELATIVE_PATHS) { + sourceContent.set(relativePath, await Bun.file(join(ctx.workspace, relativePath)).text()); + } + + // Success lines print the project path, so folder_1's `dest` prefix shows up and folder_2's + // group has none. + expect(result.stdout).toContain("Directory 'folder_2'"); + expect(result.stdout).toContain("Directory 'root'"); + expect(result.stdout).toContain("Directory 'root/folder_1'"); + expect(result.stdout).toContain("Directory 'root/folder_1/f1'"); + expect(result.stdout).toContain("Directory 'root/folder_1/f1/f2'"); + expect(result.stdout).toContain("File 'folder_2/android_1.xml'"); + expect(result.stdout).toContain("File 'folder_2/android_2.xml'"); + expect(result.stdout).toContain("File 'folder_2/android_3.xml'"); + expect(result.stdout).toContain("File 'folder_2/android_4a.xml'"); + expect(result.stdout).toContain("File 'root/folder_1/android.xml'"); + expect(result.stdout).toContain("File 'root/folder_1/f1/android.xml'"); + expect(result.stdout).toContain("File 'root/folder_1/f1/f2/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads the same nested source files to a brand-new branch', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'b1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'folder_2/android_1.xml'"); + expect(result.stdout).toContain("File 'root/folder_1/f1/f2/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads sources back to their original local paths', async () => { + await removeDownloadedSources(ctx); + + const result = await ctx.runner.run(['download', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + // folder_1's `dest` uses `%original_path%`, the source file's parent directory - so no doubled + // filename segment. + expect(result.stdout).toContain("File 'root/folder_1/android.xml'"); + expect(result.stdout).toContain("File 'root/folder_1/f1/android.xml'"); + expect(result.stdout).toContain("File 'root/folder_1/f1/f2/android.xml'"); + expect(result.stdout).toContain("File 'folder_2/android_1.xml'"); + // A `[...]` class in the source pattern matches server-side during download exactly as it does + // locally during upload. + expect(result.stdout).toContain("File 'folder_2/android_2.xml'"); + expect(result.stdout).toContain("File 'folder_2/android_3.xml'"); + expect(result.stdout).toContain("File 'folder_2/android_4a.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, ...SOURCE_RELATIVE_PATHS); + + for (const relativePath of SOURCE_RELATIVE_PATHS) { + expect(await Bun.file(join(ctx.workspace, relativePath)).text()).toBe( + capturedContent(sourceContent, relativePath), + ); + } + }); + + test('downloads sources again with --output plain', async () => { + await removeDownloadedSources(ctx); + + // `--output plain`: bare downloaded paths instead of messages. + const result = await ctx.runner.run(['download', 'sources', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // `--output plain` changes the messages, never which files are written. + await expectFilesExist(ctx.workspace, ...SOURCE_RELATIVE_PATHS); + + for (const relativePath of SOURCE_RELATIVE_PATHS) { + expect(await Bun.file(join(ctx.workspace, relativePath)).text()).toBe( + capturedContent(sourceContent, relativePath), + ); + } + }); + + test('downloads sources from the b1 branch', async () => { + await removeDownloadedSources(ctx); + + // Server paths carry the branch name; the download strips it before matching, so a branch + // resolves the same 7 files as master. + const result = await ctx.runner.run(['download', 'sources', '-b', 'b1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, ...SOURCE_RELATIVE_PATHS); + + for (const relativePath of SOURCE_RELATIVE_PATHS) { + expect(await Bun.file(join(ctx.workspace, relativePath)).text()).toBe( + capturedContent(sourceContent, relativePath), + ); + } + }); + + test('warns when a source pattern matches nothing', async () => { + await switchConfig(ctx, 'no-sources'); + + const result = await ctx.runner.run(['download', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain( + "No sources found for '/folder_not_exists/**/*.xml' pattern. Check the source paths in your configuration file", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects --reviewed on a non-Enterprise (SaaS) account', async () => { + // Restores the bytes captured in `beforeAll`, already rendered. + await Bun.write(join(ctx.workspace, 'crowdin.yml'), originalConfig); + + const result = await ctx.runner.run(['download', 'sources', '--reviewed']); + + expect(result).toMatchObject({ exitCode: 0 }); + // The test account is SaaS, not Enterprise. + expect(result.stderr).toContain('Operation is available only for Crowdin Enterprise'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the download without writing anything with --dryrun', async () => { + await removeDownloadedSources(ctx); + + const result = await ctx.runner.run(['download', 'sources', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + + // The listing carries project paths, not the local ones the files are written to. Asserted as + // an exact set: every local path is a substring of its project path, so `toContain` proves nothing. + const listed = await ctx.runner.run(['download', 'sources', '--dryrun', '--output', 'plain']); + + expect(listed.stdout.split('\n').filter(Boolean).sort()).toEqual([ + 'folder_2/android_1.xml', + 'folder_2/android_2.xml', + 'folder_2/android_3.xml', + 'folder_2/android_4a.xml', + 'root/folder_1/android.xml', + 'root/folder_1/f1/android.xml', + 'root/folder_1/f1/f2/android.xml', + ]); + + for (const relativePath of SOURCE_RELATIVE_PATHS) { + expect(await Bun.file(join(ctx.workspace, relativePath)).exists()).toBe(false); + } + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('warns about the unpredictable layout when preserve_hierarchy is off', async () => { + await switchConfig(ctx, 'flat-hierarchy'); + + const result = await ctx.runner.run(['download', 'sources', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + // The CLI's only multi-line diagnostic, so the one place line handling has to hold. + expect(result.stderr).toContain( + "Because the 'preserve_hierarchy' parameter is set to 'false':\n" + + '\t- CLI might download some unexpected files that match the pattern;\n' + + '\t- Source file hierarchy may not be preserved and will be the same as in Crowdin.', + ); + }); + + test('refuses to download sources from a string-based project', async () => { + await Bun.write(join(ctx.workspace, 'crowdin.yml'), originalConfig); + + const result = await ctx.runner.run(['download', 'sources', '--project-id', String(stringsBasedProjectId)]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('File management is not available for string-based projects'); + }); +}); diff --git a/tests/e2e/suites/download-translations-all.test.ts b/tests/e2e/suites/download-translations-all.test.ts new file mode 100644 index 000000000..ac0295af3 --- /dev/null +++ b/tests/e2e/suites/download-translations-all.test.ts @@ -0,0 +1,237 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesExist, expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * `--keep-archive` prints `Archive saved to /`, and `basePath` is absolute, so + * the line embeds the per-run workspace. + */ +function redactWorkspace(ctx: SuiteContext, text: string): string { + return text.split(ctx.workspace).join(''); +} + +/** Scanned rather than hardcoded: the name gains an `-` suffix when several export groups are built. */ +async function findKeptArchive(ctx: SuiteContext): Promise { + const entries = await readdir(join(ctx.workspace, 'files')); + return entries.find((entry) => entry.startsWith('crowdin-translations') && entry.endsWith('.zip')); +} + +async function removeKeptArchive(ctx: SuiteContext): Promise { + await rm(join(ctx.workspace, 'files', 'crowdin-translations.zip'), { force: true }); +} + +describe('download translations --all', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('download-translations-all', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'root'"); + expect(result.stdout).toContain("Directory 'root/folder'"); + expect(result.stdout).toContain("Directory 'root/{{cookiecutter.module_name}}'"); + expect(result.stdout).toContain("File 'root/android.xml'"); + expect(result.stdout).toContain("File 'root/folder/android.xml'"); + expect(result.stdout).toContain("File 'root/{{cookiecutter.module_name}}/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews downloading all translations (dry run)', async () => { + const result = await ctx.runner.run(['download', '--all', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('translations/it/android.xml'); + expect(result.stdout).toContain('translations/it/folder/android.xml'); + expect(result.stdout).toContain('translations/it/{{cookiecutter.module_name}}/android.xml'); + expect(result.stdout).toContain('translations/uk/android.xml'); + expect(result.stdout).toContain('translations/uk/folder/android.xml'); + expect(result.stdout).toContain('translations/uk/{{cookiecutter.module_name}}/android.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads all translations', async () => { + const result = await ctx.runner.run(['download', '--all']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/it/folder/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/it/{{cookiecutter.module_name}}/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/folder/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/{{cookiecutter.module_name}}/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'files', + 'expected', + 'translations/it/android.xml', + 'translations/it/folder/android.xml', + 'translations/uk/android.xml', + 'translations/uk/folder/android.xml', + ); + }); + + test('uploads sources to a new branch', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'b1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'root/android.xml'"); + expect(result.stdout).toContain("File 'root/folder/android.xml'"); + expect(result.stdout).toContain("File 'root/{{cookiecutter.module_name}}/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for the branch', async () => { + await rm(join(ctx.workspace, 'files', 'translations'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', '-b', 'b1', '--all']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/it/folder/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/it/{{cookiecutter.module_name}}/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/folder/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/{{cookiecutter.module_name}}/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'files', + 'expected', + 'translations/it/android.xml', + 'translations/it/folder/android.xml', + 'translations/uk/android.xml', + 'translations/uk/folder/android.xml', + ); + }); + + test('reports an empty archive when skipping untranslated files', async () => { + const result = await ctx.runner.run(['download', '--skip-untranslated-files']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain( + "Couldn't find any file to download. Since you are using the 'Skip untranslated files' option, please " + + 'make sure you have fully translated files', + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('keeps the downloaded archive for the branch', async () => { + await removeKeptArchive(ctx); + + const result = await ctx.runner.run(['download', '--keep-archive', '-b', 'b1', '--all']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(normalize(redactWorkspace(ctx, result.stdout))).toMatchSnapshot(); + + const zipName = await findKeptArchive(ctx); + expect(zipName).toBeDefined(); + expect(await Bun.file(join(ctx.workspace, 'files', zipName as string)).exists()).toBe(true); + + await expectFilesMatch( + ctx.workspace, + 'files', + 'expected', + 'translations/it/android.xml', + 'translations/it/folder/android.xml', + 'translations/uk/android.xml', + 'translations/uk/folder/android.xml', + ); + }); + + test('deletes the branch', async () => { + const result = await ctx.runner.run(['branch', 'delete', 'b1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Branch 'b1' deleted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('keeps the downloaded archive', async () => { + await removeKeptArchive(ctx); + + const result = await ctx.runner.run(['download', '--keep-archive', '--all']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(normalize(redactWorkspace(ctx, result.stdout))).toMatchSnapshot(); + + const zipName = await findKeptArchive(ctx); + expect(zipName).toBeDefined(); + expect(await Bun.file(join(ctx.workspace, 'files', zipName as string)).exists()).toBe(true); + }); + + test('keeps the downloaded archive for a single language', async () => { + await removeKeptArchive(ctx); + + const result = await ctx.runner.run(['download', '--keep-archive', '-l', 'uk', '--all']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Building translations for languages: uk'); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/folder/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/{{cookiecutter.module_name}}/android.xml' extracted"); + expect(normalize(redactWorkspace(ctx, result.stdout))).toMatchSnapshot(); + + const zipName = await findKeptArchive(ctx); + expect(zipName).toBeDefined(); + expect(await Bun.file(join(ctx.workspace, 'files', zipName as string)).exists()).toBe(true); + }); + + // With `--output plain`, the closing summary lists the kept zip and + // the extracted paths. + test('keeps the downloaded archive with plain output', async () => { + await removeKeptArchive(ctx); + + const result = await ctx.runner.run(['download', '--keep-archive', '--output', 'plain', '--all']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(redactWorkspace(ctx, result.stdout))).toMatchSnapshot(); + + const zipName = await findKeptArchive(ctx); + expect(zipName).toBeDefined(); + expect(await Bun.file(join(ctx.workspace, 'files', zipName as string)).exists()).toBe(true); + + await expectFilesExist(ctx.workspace, 'files/translations/it/android.xml', 'files/translations/uk/android.xml'); + }); + + test('narrows the build to the languages left after --exclude-language', async () => { + const result = await ctx.runner.run(['download', 'translations', '--exclude-language', 'it', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + // Excludes subtract from the project's languages rather than replacing the set, and narrowing + // it pins the build (lib/download/languages.ts). + expect(result.stdout).toContain('translations/uk/'); + expect(result.stdout).not.toContain('translations/it/'); + }); + + test('rejects --language and --exclude-language together', async () => { + const result = await ctx.runner.run(['download', 'translations', '-l', 'uk', '--exclude-language', 'it']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--language' and '--exclude-language' options can't be used simultaneously"); + }); + + test('rejects an excluded language the project does not target', async () => { + const result = await ctx.runner.run(['download', 'translations', '--exclude-language', 'de', '--dryrun']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Language 'de' doesn't exist in the project"); + }); +}); diff --git a/tests/e2e/suites/env-variables.test.ts b/tests/e2e/suites/env-variables.test.ts new file mode 100644 index 000000000..efedc4279 --- /dev/null +++ b/tests/e2e/suites/env-variables.test.ts @@ -0,0 +1,105 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesExist, expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * `crowdin.yml` here has only the `*_env` keys, no literal credentials. Their values come from a + * workspace-root `.env` that Bun auto-loads into `process.env` at CLI startup, so it has to be + * written before any command that needs them runs - `setupSuite` only renders `crowdin.yml`. + */ +async function writeEnvFile(ctx: SuiteContext, apiToken: string): Promise { + await Bun.write( + join(ctx.workspace, '.env'), + [ + `TEST_PROJECT_ID_ENV=${ctx.project.id}`, + `TEST_API_TOKEN_ENV=${apiToken}`, + `TEST_BASE_PATH_ENV=${ctx.workspace}`, + `TEST_BASE_URL_ENV=https://api.crowdin.com`, + ].join('\n'), + ); +} + +describe('env variables', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('env-variables', { targetLanguageIds: ['it', 'uk'] }); + await writeEnvFile(ctx, ctx.env.token as string); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources using credentials read from an env file', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations using credentials read from an env file', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/it/2_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations using credentials read from an env file', async () => { + // Uploaded translations already occupy translations// (same path the download + // lands at, per the /translations/%two_letters_code%/%original_file_name% pattern) - clear it + // first so the assertions below only see what this download produced. + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/1_android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/it/2_android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist( + ctx.workspace, + 'translations/it/1_android.xml', + 'translations/it/2_android.xml', + 'translations/uk/1_android.xml', + 'translations/uk/2_android.xml', + ); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); + + test('a process-level env var overrides an invalid token in the env file on re-upload', async () => { + // Re-point .env at a deliberately broken token; the real token is supplied only as a + // process-level override below, which must win for the upload to succeed. + await writeEnvFile(ctx, 'invalid-token'); + + const result = await ctx.runner.run(['upload', 'sources'], { + env: { TEST_API_TOKEN_ENV: ctx.env.token as string }, + }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/excluded-languages.test.ts b/tests/e2e/suites/excluded-languages.test.ts new file mode 100644 index 000000000..2a4e56967 --- /dev/null +++ b/tests/e2e/suites/excluded-languages.test.ts @@ -0,0 +1,217 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** Run before a config switch, so the next `upload sources` recreates every file under the new group(s). */ +async function deleteAllProjectFiles(ctx: SuiteContext): Promise { + const files = await ctx.client.sourceFilesApi.listProjectFiles(ctx.project.id); + for (const file of files.data) { + await ctx.client.sourceFilesApi.deleteFile(ctx.project.id, file.data.id); + } +} + +/** + * `download translations` only writes the languages included in the current build — it never + * removes files from a previous download. Every test but the first re-downloads into the same + * workspace, so a language excluded *this* run would otherwise still "exist" from an earlier run. + * Clear the destination first so each download test's `exists()` assertions reflect this run only. + */ +async function clearDownloadedTranslations(ctx: SuiteContext): Promise { + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); +} + +describe('excluded languages', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('excluded-languages', { targetLanguageIds: ['it', 'uk', 'de'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources excluding a language via the CLI flag', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--excluded-language', 'de']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations, skipping the language with no local files', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("File 'translations/de/1_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/de/2_android.xml' does not exist in the specified location"); + expect(result.stdout).toContain("File 'translations/it/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/it/2_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists configured translation files for every target language regardless of exclusions', async () => { + const result = await ctx.runner.run(['config', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('translations/de/1_android.xml'); + expect(result.stdout).toContain('translations/it/1_android.xml'); + expect(result.stdout).toContain('translations/uk/1_android.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations, building only the non-excluded languages', async () => { + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + expect(await Bun.file(join(ctx.workspace, 'translations/de/1_android.xml')).exists()).toBe(false); + }); + + test('changes the excluded language via a new CLI flag value', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--excluded-language', 'it']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('re-uploads translations, rejecting the newly excluded language', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("File 'translations/de/1_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/de/2_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain( + "Translation file 'translations/it/1_android.xml' hasn't been uploaded since the following target " + + 'language(s) are not enabled for the source file in your Crowdin project', + ); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations after the excluded language changed', async () => { + await clearDownloadedTranslations(ctx); + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await Bun.file(join(ctx.workspace, 'translations/de/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/it/1_android.xml')).exists()).toBe(false); + }); + + test('uploads sources without the CLI flag, leaving the exclusion unchanged', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations, exclusion unchanged since the flag was omitted', async () => { + await clearDownloadedTranslations(ctx); + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // Omitting --excluded-language does not clear a previously-set exclusion (only an explicit + // value replaces it), so 'it' is still excluded here even though this run passed no flag. + expect(await Bun.file(join(ctx.workspace, 'translations/de/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/it/1_android.xml')).exists()).toBe(false); + }); + + test('uploads sources with exclusion declared in the config file', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'crowdin-excluded-languages'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations honoring the config-declared exclusion', async () => { + await clearDownloadedTranslations(ctx); + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await Bun.file(join(ctx.workspace, 'translations/de/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/it/1_android.xml')).exists()).toBe(false); + }); + + test('uploads sources merging the config exclusion with the CLI flag', async () => { + await deleteAllProjectFiles(ctx); + + const result = await ctx.runner.run(['upload', 'sources', '--excluded-language', 'de']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations honoring the merged config+CLI exclusion', async () => { + await clearDownloadedTranslations(ctx); + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // Both the config's 'it' and the CLI flag's 'de' are excluded (merged, not overridden). + expect(await Bun.file(join(ctx.workspace, 'translations/uk/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/it/1_android.xml')).exists()).toBe(false); + expect(await Bun.file(join(ctx.workspace, 'translations/de/1_android.xml')).exists()).toBe(false); + }); + + test('uploads sources with per-file exclusions across two file groups', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'crowdin-two-groups'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations honoring per-file exclusions', async () => { + await clearDownloadedTranslations(ctx); + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // 1_android.xml excludes 'it' -> de/uk only. 2_android.xml excludes 'uk' -> de/it only. + expect(await Bun.file(join(ctx.workspace, 'translations/de/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/1_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/it/1_android.xml')).exists()).toBe(false); + expect(await Bun.file(join(ctx.workspace, 'translations/de/2_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/it/2_android.xml')).exists()).toBe(true); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/2_android.xml')).exists()).toBe(false); + }); + + test('rejects an excluded language that does not exist in the project', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--excluded-language', 'ar']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't have 'ar' language(s)"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/export-options.test.ts b/tests/e2e/suites/export-options.test.ts new file mode 100644 index 000000000..887bf4b78 --- /dev/null +++ b/tests/e2e/suites/export-options.test.ts @@ -0,0 +1,374 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { copyFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * `download translations` never removes stale files from a previous download, and several tests + * below download a strict subset of an earlier test's file set, so the destination is cleared first. + */ +async function clearDownloadedTranslations(ctx: SuiteContext): Promise { + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); +} + +describe('export options', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('export-options', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + /** + * Approvals the project holds for both target languages - 0 until something approves a + * translation. The endpoint rejects a call without `languageId`, so it is asked per language. + */ + async function approvalCount(): Promise { + const counts = await Promise.all( + ['it', 'uk'].map(async (languageId) => { + const response = await ctx.client.stringTranslationsApi + .withFetchAll() + .listTranslationApprovals(ctx.project.id, { languageId }); + + return response.data.length; + }), + ); + + return counts.reduce((total, count) => total + count, 0); + } + + test('uploads sources for both files', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '1_android.xml'"); + expect(result.stdout).toContain("File '2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const configResult = await ctx.runner.run(['config', 'sources']); + + expect(configResult).toMatchObject({ exitCode: 0 }); + expect(configResult.stdout).toContain('1_android.xml'); + expect(configResult.stdout).toContain('2_android.xml'); + }); + + test('reports no fully translated files when skipping untranslated files via the CLI flag, before any translations exist', async () => { + const result = await ctx.runner.run(['download', 'translations', '--skip-untranslated-files']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain( + "Couldn't find any file to download. Since you are using the 'Skip untranslated files' option, please " + + 'make sure you have fully translated files', + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports no fully translated files when skip_untranslated_files is set in config, before any translations exist', async () => { + await switchConfig(ctx, 'skip-untranslated-files'); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain( + "Couldn't find any file to download. Since you are using the 'Skip untranslated files' option, please " + + 'make sure you have fully translated files', + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for both languages', async () => { + // Revert to the plain config - the previous test left `skip_untranslated_files: true` in place, + // which would otherwise silently force every later "plain" download to skip untranslated files too. + await switchConfig(ctx, 'base'); + + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/it/2_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('approves translations by re-uploading the approved fixtures with auto-approve', async () => { + await copyFile( + join(ctx.workspace, 'translations-approved/it/1_android.xml'), + join(ctx.workspace, 'translations/it/1_android.xml'), + ); + await copyFile( + join(ctx.workspace, 'translations-approved/it/2_android.xml'), + join(ctx.workspace, 'translations/it/2_android.xml'), + ); + await copyFile( + join(ctx.workspace, 'translations-approved/uk/1_android.xml'), + join(ctx.workspace, 'translations/uk/1_android.xml'), + ); + await copyFile( + join(ctx.workspace, 'translations-approved/uk/2_android.xml'), + join(ctx.workspace, 'translations/uk/2_android.xml'), + ); + + // Nothing is approved yet: the runs above imported translations without the flag. + expect(await approvalCount()).toBe(0); + + const result = await ctx.runner.run(['upload', 'translations', '--auto-approve-imported']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/it/2_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // The flag's only effect is the approval, and it is invisible in the output above. + expect(await approvalCount()).toBeGreaterThan(0); + }); + + test('downloads translations skipping untranslated strings via the CLI flag', async () => { + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--skip-untranslated-strings']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/skip-strings', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); + + test('downloads translations skipping untranslated files via the CLI flag', async () => { + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--skip-untranslated-files']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/skip-files', + 'it/1_android.xml', + 'uk/1_android.xml', + ); + }); + + test('downloads translations exporting only approved translations via the CLI flag', async () => { + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '--export-only-approved']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/approved', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); + + test('downloads translations skipping untranslated strings and exporting only approved via CLI flags', async () => { + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run([ + 'download', + 'translations', + '--skip-untranslated-strings', + '--export-only-approved', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/skip-strings-approved', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); + + test('downloads translations skipping untranslated files and exporting only approved via CLI flags', async () => { + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run([ + 'download', + 'translations', + '--skip-untranslated-files', + '--export-only-approved', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // uk/1_android.xml is 100% translated and approved, so skipping untranslated *files* and skipping + // untranslated *strings* converge on the same output for it. + await expectFilesMatch(ctx.workspace, 'translations', 'expected/skip-strings-approved', 'uk/1_android.xml'); + }); + + test('rejects skipping untranslated strings and files at the same time', async () => { + const result = await ctx.runner.run([ + 'download', + 'translations', + '--skip-untranslated-strings', + '--skip-untranslated-files', + ]); + + // DownloadCommand.ts throws a plain CliError, which exits 1. + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'You cannot skip strings and files at the same time. Please use one of these parameters instead.', + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations with skip_untranslated_strings set in config', async () => { + await clearDownloadedTranslations(ctx); + await switchConfig(ctx, 'skip-untranslated-strings'); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/skip-strings', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); + + test('downloads translations with skip_untranslated_files set in config', async () => { + await clearDownloadedTranslations(ctx); + await switchConfig(ctx, 'skip-untranslated-files'); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/skip-files', + 'it/1_android.xml', + 'uk/1_android.xml', + ); + }); + + test('downloads translations with export_only_approved set in config', async () => { + await clearDownloadedTranslations(ctx); + await switchConfig(ctx, 'export-only-approved'); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/approved', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); + + test('downloads translations with skip_untranslated_strings and export_only_approved set in config', async () => { + await clearDownloadedTranslations(ctx); + await switchConfig(ctx, 'skip-strings-approved'); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/skip-strings-approved', + 'it/1_android.xml', + 'it/2_android.xml', + 'uk/1_android.xml', + 'uk/2_android.xml', + ); + }); + + test('downloads translations with skip_untranslated_files and export_only_approved set in config', async () => { + await clearDownloadedTranslations(ctx); + await switchConfig(ctx, 'skip-files-approved'); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations', 'expected/skip-strings-approved', 'uk/1_android.xml'); + }); + + test('warns and ignores export_strings_that_passed_workflow outside Enterprise', async () => { + await switchConfig(ctx, 'passed-workflow'); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain('Exporting strings that passed workflow is supported only for Crowdin Enterprise'); + expect(result.stdout).toContain("File 'translations/it/1_android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/it/2_android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/2_android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations across two file groups with different export options in config', async () => { + await clearDownloadedTranslations(ctx); + await switchConfig(ctx, 'two-groups'); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/1_android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/1_android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected/skip-strings-approved', + 'it/1_android.xml', + 'uk/1_android.xml', + ); + + // 2_android.xml has skip_untranslated_files set in its group and is not fully translated in + // either language, so it is omitted from both language builds entirely. + expect(await Bun.file(join(ctx.workspace, 'translations/it/2_android.xml')).exists()).toBe(false); + expect(await Bun.file(join(ctx.workspace, 'translations/uk/2_android.xml')).exists()).toBe(false); + }); +}); diff --git a/tests/e2e/suites/file-groups.test.ts b/tests/e2e/suites/file-groups.test.ts new file mode 100644 index 000000000..411738c7c --- /dev/null +++ b/tests/e2e/suites/file-groups.test.ts @@ -0,0 +1,90 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { captureAndClear, expectFilesExist, expectRestored } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Four overlapping file groups over + * the same `sources/` files, two of them ('*.xml' and the literal 'android.xml') matching the same + * file and one ('*.pot') matching nothing. + * + * The three commands disagree about an empty pattern: `upload sources` reports it and exits 1, + * while both translation commands exit 0. + */ +describe('file groups', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('file-groups', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources across overlapping file groups, then reports the empty pattern', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + // A group matching zero files is a soft error: the other groups still upload. + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Current execution finished with errors'); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain("File 'sources/java.properties'"); + expect(result.stdout).toContain("File 'sources/android.xml'"); + // The second group to reach android.xml skips it - `upload sources` dedups by project path. + expect(result.stderr).toContain("Skipping file 'sources/android.xml' because it is already uploading/uploaded"); + expect(result.stderr).toContain( + "No sources found for '/sources/*.pot' pattern. Check the source paths in your configuration file", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'sources/android.xml', 'sources/java.properties'); + }); + + test('uploads translations, duplicating the file matched by both overlapping groups', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + // Same empty pattern as above, but here it does not fail the run. + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain( + "No sources found for '/sources/*.pot' pattern. Check the source paths in your configuration file", + ); + expect(result.stdout).toContain("File 'translations/it/java.properties'"); + expect(result.stdout).toContain("File 'translations/uk/java.properties'"); + + // No dedup across file groups here, so the doubly-matched android.xml uploads twice per language. + const italianUploads = result.stdout.split("File 'translations/it/android.xml'").length - 1; + const ukrainianUploads = result.stdout.split("File 'translations/uk/android.xml'").length - 1; + expect(italianUploads).toBe(2); + expect(ukrainianUploads).toBe(2); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations, deduplicating the file matched by both overlapping groups', async () => { + // The upload fixtures already sit at these paths, so clear them first - otherwise the existence + // check below passes even if the download writes nothing. + const captured = await captureAndClear( + ctx.workspace, + 'translations/it/android.xml', + 'translations/it/java.properties', + 'translations/uk/android.xml', + 'translations/uk/java.properties', + ); + + const result = await ctx.runner.run(['download', 'translations']); + + // The empty pattern passes silently here, and the doubly-matched android.xml collapses to one + // line per language: the download maps by path, so the second group's entry overwrites the first. + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/it/java.properties' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/java.properties' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // The configured `translation:` pattern is absolute, so downloads land at it verbatim rather + // than at '/'. + await expectRestored(ctx.workspace, captured); + }); +}); diff --git a/tests/e2e/suites/file-tree.test.ts b/tests/e2e/suites/file-tree.test.ts new file mode 100644 index 000000000..3463b49e9 --- /dev/null +++ b/tests/e2e/suites/file-tree.test.ts @@ -0,0 +1,374 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { listFilesRecursively } from '../helpers/files.ts'; +import { projectFilePaths } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Exercises `upload sources` / + * `upload translations` / `download translations` / `file list` against a real (trimmed) NetBeans + * PHP-module source tree, both on the default branch and on a brand-new branch, asserting the deep + * nested directory hierarchy gets created correctly and the exact set of server-side file paths. + */ + +const EXPECTED_LOCAL_FILES_AFTER_DOWNLOAD = [ + 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties', + 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties', + 'it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties', + 'it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties', + 'it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties', + 'php/hudson.php/nbproject/project.properties', + 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties', + 'php/hudson.php/src/org/netbeans/modules/hudson/php/ui/options/HudsonOptionsPanel.form', + 'php/libs.javacup/external/binaries-list', + 'php/libs.javacup/external/java-cup-11a-license.txt', + 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties', + 'php/php.api.annotation/nbproject/project.properties', + 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties', + 'php/php.api.documentation/nbproject/project.properties', + 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties', + 'php/php.api.editor/nbproject/project.properties', + 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties', + 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties', + 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties', + 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties', + 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties', + 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties', +].sort(); + +const MASTER_SOURCE_FILE_PATHS = [ + '/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties', + '/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties', + '/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties', + '/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties', + '/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties', +].sort(); + +const BRANCH_SOURCE_FILE_PATHS = MASTER_SOURCE_FILE_PATHS.map((path) => `/branch1${path}`).sort(); + +describe('file tree', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('file-tree', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('previews uploading sources as a dry run', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + "File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' would be created", + ); + expect(result.stdout).toContain( + "File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' would be created", + ); + expect(result.stdout).toContain( + "File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties' would be created", + ); + expect(result.stdout).toContain( + "File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties' would be created", + ); + expect(result.stdout).toContain( + "File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties' would be created", + ); + expect(result.stdout).not.toContain('Directory '); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources, creating the full nested directory hierarchy', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + // Representative sample of the 42 distinct directories this creates; the rest is snapshotted. + expect(result.stdout).toContain("Directory 'php'"); + expect(result.stdout).toContain("Directory 'php/hudson.php'"); + expect(result.stdout).toContain("Directory 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources'"); + expect(result.stdout).toContain("Directory 'php/libs.javacup/src/org/netbeans/libs/javacup'"); + expect(result.stdout).toContain( + "File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual(MASTER_SOURCE_FILE_PATHS); + }); + + test('updates the existing sources (no new directories)', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).not.toContain('created'); + expect(result.stdout).toContain( + "File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual(MASTER_SOURCE_FILE_PATHS); + }); + + test('uploads translations for a single language (uk)', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-l', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + "File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'", + ); + expect(result.stdout).not.toContain("File 'it/"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for all languages', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + "File 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'it/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'it/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'it/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews downloading translations as a dry run', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties', + ); + expect(result.stdout).toContain('uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations, overwriting the local it/uk trees', async () => { + // Prove the download recreates these from the server rather than finding them on disk. + await rm(join(ctx.workspace, 'files', 'it'), { recursive: true, force: true }); + await rm(join(ctx.workspace, 'files', 'uk'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + "File 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' extracted", + ); + expect(result.stdout).toContain( + "File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' extracted", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await listFilesRecursively(join(ctx.workspace, 'files'))).toEqual(EXPECTED_LOCAL_FILES_AFTER_DOWNLOAD); + }); + + test('lists the uploaded source files', async () => { + const result = await ctx.runner.run(['file', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'); + expect(result.stdout).toContain('php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'); + expect(result.stdout).toContain( + 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties', + ); + expect(result.stdout).toContain( + 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties', + ); + expect(result.stdout).toContain( + 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties', + ); + }); + + // --- Branch coverage from here: the SAME local tree uploaded again under a brand-new branch. --- + + test('uploads sources to a brand-new branch, creating the directory hierarchy again', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'branch1']); + + expect(result).toMatchObject({ exitCode: 0 }); + // No branch-creation message, and the paths carry no "branch1/" prefix, so these match the + // non-branch upload above. + expect(result.stdout).toContain("Directory 'php'"); + expect(result.stdout).toContain("Directory 'php/hudson.php'"); + expect(result.stdout).toContain("Directory 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources'"); + expect(result.stdout).toContain( + "File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual([...MASTER_SOURCE_FILE_PATHS, ...BRANCH_SOURCE_FILE_PATHS].sort()); + }); + + // The second upload to an existing branch is the update path: the existing-file lookup strips the + // branch prefix, so nothing is re-created. + test('updates sources on the branch (branch already exists)', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'branch1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).not.toContain('created'); + expect(result.stdout).toContain( + "File 'php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual([...MASTER_SOURCE_FILE_PATHS, ...BRANCH_SOURCE_FILE_PATHS].sort()); + }); + + test('uploads translations for a single language (uk) on the branch', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-b', 'branch1', '-l', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + "File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'uk/php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'uk/php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties'", + ); + expect(result.stdout).toContain( + "File 'uk/php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties'", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for all languages on the branch', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-b', 'branch1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + "File 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'it/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(result.stdout).toContain( + "File 'uk/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'", + ); + expect(result.stdout).toContain("File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews downloading translations on the branch as a dry run', async () => { + const result = await ctx.runner.run(['download', 'translations', '-b', 'branch1', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties', + ); + expect(result.stdout).toContain('uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations on the branch, overwriting the local it/uk trees again', async () => { + await rm(join(ctx.workspace, 'files', 'it'), { recursive: true, force: true }); + await rm(join(ctx.workspace, 'files', 'uk'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations', '-b', 'branch1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain( + "File 'it/php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties' extracted", + ); + expect(result.stdout).toContain( + "File 'uk/php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties' extracted", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // The branch build downloads into the exact same local destination as the master build (the + // local landing path never carries the branch name), so the recursive listing is unchanged. + expect(await listFilesRecursively(join(ctx.workspace, 'files'))).toEqual(EXPECTED_LOCAL_FILES_AFTER_DOWNLOAD); + }); + + test('lists source files on the branch', async () => { + const result = await ctx.runner.run(['file', 'list', '-b', 'branch1']); + + expect(result).toMatchObject({ exitCode: 0 }); + // Unlike upload's success messages, `file list` prints the raw server path, so these DO carry the + // "branch1/" prefix. + expect(result.stdout).toContain('php/hudson.php/src/org/netbeans/modules/hudson/php/resources/Bundle.properties'); + expect(result.stdout).toContain('php/libs.javacup/src/org/netbeans/libs/javacup/Bundle.properties'); + expect(result.stdout).toContain( + 'php/php.api.annotation/src/org/netbeans/modules/php/api/annotation/resources/Bundle.properties', + ); + expect(result.stdout).toContain( + 'php/php.api.documentation/src/org/netbeans/modules/php/api/documentation/resources/Bundle.properties', + ); + expect(result.stdout).toContain( + 'php/php.api.editor/src/org/netbeans/modules/php/api/editor/resources/Bundle.properties', + ); + }); +}); diff --git a/tests/e2e/suites/file-type.test.ts b/tests/e2e/suites/file-type.test.ts new file mode 100644 index 000000000..213eb847f --- /dev/null +++ b/tests/e2e/suites/file-type.test.ts @@ -0,0 +1,128 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * The per-file `type:` config key reaching the + * file-create API call. + * + * The API splits a file type into `type` (the base format) plus `parserVersion`, so the assertions below check both. A bare `type: + * "android"` resolves to whatever parser version the backend currently defaults to. + */ +async function deleteAllProjectFiles(ctx: SuiteContext): Promise { + const files = await ctx.client.sourceFilesApi.listProjectFiles(ctx.project.id); + for (const file of files.data) { + await ctx.client.sourceFilesApi.deleteFile(ctx.project.id, file.data.id); + } +} + +interface UploadedFileType { + type: string | undefined; + parserVersion: number | undefined; +} + +async function getUploadedFileType(ctx: SuiteContext): Promise { + const files = await ctx.client.sourceFilesApi.listProjectFiles(ctx.project.id); + const file = files.data.find((f) => f.data.name === 'android.xml')?.data; + return { type: file?.type, parserVersion: file?.parserVersion }; +} + +describe('file type', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('file-type', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('type "android6" is stored as android with parserVersion 6', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'file-type', { type: 'android6' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await getUploadedFileType(ctx)).toEqual({ type: 'android', parserVersion: 6 }); + }); + + test('type "android8" is stored as android with parserVersion 8', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'file-type', { type: 'android8' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await getUploadedFileType(ctx)).toEqual({ type: 'android', parserVersion: 8 }); + }); + + test('type "android" is normalized to parserVersion 11', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'file-type', { type: 'android' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await getUploadedFileType(ctx)).toEqual({ type: 'android', parserVersion: 11 }); + }); + + test('type "android5" is stored as android with parserVersion 5', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'file-type', { type: 'android5' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await getUploadedFileType(ctx)).toEqual({ type: 'android', parserVersion: 5 }); + }); + + test('type "android4" is stored as android with parserVersion 4', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'file-type', { type: 'android4' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await getUploadedFileType(ctx)).toEqual({ type: 'android', parserVersion: 4 }); + }); + + test('type "android3" is stored as android with parserVersion 3', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'file-type', { type: 'android3' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await getUploadedFileType(ctx)).toEqual({ type: 'android', parserVersion: 3 }); + }); + + test('type "android2" is stored as android with parserVersion 2', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'file-type', { type: 'android2' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await getUploadedFileType(ctx)).toEqual({ type: 'android', parserVersion: 2 }); + }); + + test('type "android1" is normalized to parserVersion 1', async () => { + await deleteAllProjectFiles(ctx); + await switchConfig(ctx, 'file-type', { type: 'android1' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await getUploadedFileType(ctx)).toEqual({ type: 'android', parserVersion: 1 }); + }); +}); diff --git a/tests/e2e/suites/file.test.ts b/tests/e2e/suites/file.test.ts new file mode 100644 index 000000000..da92b787d --- /dev/null +++ b/tests/e2e/suites/file.test.ts @@ -0,0 +1,229 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { expectFilesExist } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `file upload` / `download` / `delete` (`cli/commands/file/FileCommand.ts`). + * + * These address a single file by its Crowdin path, which is a different code path from the + * config-driven `upload sources` / `download translations` the rest of the suites exercise - no + * `files` section is involved, the destination comes from the argument and `--dest`. + */ +const SOURCE_FILE = 'sources/app.xml'; +const EXTRA_FILE = 'sources/extra.xml'; +const BRANCH = 'feature'; + +describe('file', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('file'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + async function listedPaths(args: string[] = []): Promise { + return (await runJson<{ path: string }[]>(ctx, ['file', 'list', ...args])).map((file) => file.path).sort(); + } + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['file']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage source files and translations in a Crowdin project'); + + for (const subcommand of ['list', 'upload', 'download', 'delete']) { + expect(result.stdout).toContain(subcommand); + } + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['file', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('requires a file path on upload', async () => { + const result = await ctx.runner.run(['file', 'upload']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'file'"); + }); + + test('fails to upload a local file that does not exist', async () => { + const result = await ctx.runner.run(['file', 'upload', 'sources/missing.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File 'sources/missing.xml' not found in the Crowdin project"); + }); + + test('refuses to upload a directory', async () => { + const result = await ctx.runner.run(['file', 'upload', 'sources']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('The specified file is a directory'); + }); + + test('requires --type alongside --parser-version', async () => { + const result = await ctx.runner.run(['file', 'upload', SOURCE_FILE, '--parser-version', '2']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--type' is required for '--parser-version' option"); + }); + + test('requires --language for an offline translation file', async () => { + const result = await ctx.runner.run(['file', 'upload', SOURCE_FILE, '--xliff']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--language' parameter is required for offline translation file"); + }); + + test('refuses --dest for an offline translation file', async () => { + const result = await ctx.runner.run(['file', 'upload', SOURCE_FILE, '--xliff', '-l', 'uk', '-d', '/somewhere']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--dest' parameter can not be used for offline translation file"); + }); + + test('uploads a file, creating its directory', async () => { + const result = await ctx.runner.run(['file', 'upload', SOURCE_FILE]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain(SOURCE_FILE); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await listedPaths()).toEqual([`/${SOURCE_FILE}`]); + }); + + test('updates the file on a second upload', async () => { + const result = await ctx.runner.run(['file', 'upload', SOURCE_FILE]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`File '${SOURCE_FILE}'`); + expect(await listedPaths()).toEqual([`/${SOURCE_FILE}`]); + }); + + test('skips an existing file with --no-auto-update', async () => { + const result = await ctx.runner.run(['file', 'upload', SOURCE_FILE, '--no-auto-update']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`Project already contains the file '${SOURCE_FILE}'`); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + // plain lists only what changed, so a skipped upload prints nothing at all (`reportFiles`). + test('reports a skipped upload in json but not in plain', async () => { + expect(await runJson(ctx, ['file', 'upload', SOURCE_FILE, '--no-auto-update'])).toEqual([ + { path: SOURCE_FILE, action: 'skipped', reason: 'auto-update disabled' }, + ]); + + const plain = await ctx.runner.run(['file', 'upload', SOURCE_FILE, '--no-auto-update', '--output', 'plain']); + + expect(plain).toMatchObject({ exitCode: 0 }); + expect(plain.stdout.trim()).toBe(''); + }); + + test('uploads a file to a --dest path of its own', async () => { + const result = await ctx.runner.run(['file', 'upload', SOURCE_FILE, '-d', '/custom/renamed.xml']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'custom'"); + expect(result.stdout).toContain('custom/renamed.xml'); + expect(await listedPaths()).toEqual(['/custom/renamed.xml', `/${SOURCE_FILE}`]); + }); + + test('uploads a file into a branch it creates', async () => { + const result = await ctx.runner.run(['file', 'upload', EXTRA_FILE, '-b', BRANCH]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`Branch '${BRANCH}'`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await listedPaths(['-b', BRANCH])).toEqual([`/${EXTRA_FILE}`]); + // The root listing is scoped to the files outside every branch. + expect(await listedPaths()).toEqual(['/custom/renamed.xml', `/${SOURCE_FILE}`]); + }); + + test('downloads a source file back to its own path', async () => { + const result = await ctx.runner.run(['file', 'download', `/${SOURCE_FILE}`]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`File '/${SOURCE_FILE}'`); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await Bun.file(join(ctx.workspace, SOURCE_FILE)).text()).toContain('Welcome aboard'); + }); + + test('downloads a source file into --dest', async () => { + const result = await ctx.runner.run(['file', 'download', `/${SOURCE_FILE}`, '-d', 'downloaded']); + + expect(result).toMatchObject({ exitCode: 0 }); + await expectFilesExist(ctx.workspace, 'downloaded/app.xml'); + }); + + test('fails to download a file the project does not hold', async () => { + const result = await ctx.runner.run(['file', 'download', '/sources/missing.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File '/sources/missing.xml' not found in the Crowdin project"); + }); + + test('uploads a translation for a file', async () => { + const result = await ctx.runner.run(['file', 'upload', 'translations/uk/app.xml', '-l', 'uk', '-d', SOURCE_FILE]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/uk/app.xml'"); + }); + + test('downloads the translations of a file', async () => { + const result = await ctx.runner.run(['file', 'download', `/${SOURCE_FILE}`, '-l', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`File 'uk/${SOURCE_FILE}'`); + expect(await Bun.file(join(ctx.workspace, 'uk', SOURCE_FILE)).text()).toContain('Ласкаво просимо'); + }); + + test('rejects a language the project does not have', async () => { + const result = await ctx.runner.run(['file', 'download', `/${SOURCE_FILE}`, '-l', 'de']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Language 'de' doesn't exist in the project"); + }); + + test('fails to delete a file the project does not hold', async () => { + const result = await ctx.runner.run(['file', 'delete', '/sources/missing.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File '/sources/missing.xml' not found in the Crowdin project"); + }); + + test('deletes a file inside a branch', async () => { + const result = await ctx.runner.run(['file', 'delete', `/${EXTRA_FILE}`, '-b', BRANCH]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`File '/${EXTRA_FILE}' deleted`); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await listedPaths(['-b', BRANCH])).toEqual([]); + }); + + test('deletes a file', async () => { + const result = await ctx.runner.run(['file', 'delete', '/custom/renamed.xml']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File '/custom/renamed.xml' deleted"); + expect(await listedPaths()).toEqual([`/${SOURCE_FILE}`]); + }); + + // An empty argument satisfies commander's `` and reaches the command's own guard - the + // shape of `crowdin file download "$VAR"` with an unset variable. + test.each([['upload'], ['download'], ['delete']])('rejects an empty file path on %s', async (subcommand) => { + const result = await ctx.runner.run(['file', subcommand, '']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('File path is required'); + }); +}); diff --git a/tests/e2e/suites/full-cli-workflow.test.ts b/tests/e2e/suites/full-cli-workflow.test.ts new file mode 100644 index 000000000..beaf2a1d1 --- /dev/null +++ b/tests/e2e/suites/full-cli-workflow.test.ts @@ -0,0 +1,243 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { capturedContent, expectFilesExist } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +describe('full CLI project workflow', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('full-cli-workflow', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('previews the source upload', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the source upload as a tree', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--dryrun', '--tree']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the source upload as plain output', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--dryrun', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads all source files to a fresh project', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates existing source files', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation upload', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation upload as a tree', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun', '--tree']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation upload as plain output', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for every target language', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for a single language', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--language', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation download', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation download as a tree', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun', '--tree']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation download as plain output', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for every target language', async () => { + // Download lands at the configured `translation:` pattern (translations//) here — + // the same local path the upload-translations fixtures already occupy, so capture their content + // before the download overwrites them, instead of comparing a file against itself afterwards. + const uploadedContent = new Map(); + for (const language of ['it', 'uk']) { + for (const file of ['alpha.md', 'beta.md', 'gamma.md']) { + const key = `${language}/${file}`; + uploadedContent.set(key, await Bun.file(join(ctx.workspace, 'translations', language, file)).text()); + } + } + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist( + ctx.workspace, + 'translations/it/alpha.md', + 'translations/it/beta.md', + 'translations/it/gamma.md', + 'translations/uk/alpha.md', + 'translations/uk/beta.md', + 'translations/uk/gamma.md', + ); + + for (const language of ['it', 'uk']) { + for (const file of ['alpha.md', 'beta.md', 'gamma.md']) { + const downloaded = await Bun.file(join(ctx.workspace, 'translations', language, file)).text(); + expect(downloaded).toBe(capturedContent(uploadedContent, `${language}/${file}`)); + } + } + }); + + test('downloads translations for a single language', async () => { + await rm(join(ctx.workspace, 'translations', 'it'), { recursive: true, force: true }); + await rm(join(ctx.workspace, 'translations', 'uk'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations', '--language', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist( + ctx.workspace, + 'translations/uk/alpha.md', + 'translations/uk/beta.md', + 'translations/uk/gamma.md', + ); + expect(await Bun.file(join(ctx.workspace, 'translations/it/alpha.md')).exists()).toBe(false); + }); + + test('lists project source files', async () => { + const result = await ctx.runner.run(['file', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('sources/alpha.md'); + expect(result.stdout).toContain('sources/beta.md'); + expect(result.stdout).toContain('sources/gamma.md'); + }); + + test('lists project source files as a tree', async () => { + const result = await ctx.runner.run(['file', 'list', '--tree']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists configured source files', async () => { + const result = await ctx.runner.run(['config', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists configured source files as a tree', async () => { + const result = await ctx.runner.run(['config', 'sources', '--tree']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists configured translation files', async () => { + const result = await ctx.runner.run(['config', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists configured translation files as a tree', async () => { + const result = await ctx.runner.run(['config', 'translations', '--tree']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists target languages', async () => { + const result = await ctx.runner.run(['language', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads sources', async () => { + const result = await ctx.runner.run(['download', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'sources/alpha.md', 'sources/beta.md', 'sources/gamma.md'); + }); + + test('validates a correct configuration file', async () => { + const result = await ctx.runner.run(['config', 'lint']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports an invalid configuration file', async () => { + const result = await ctx.runner.run([ + 'config', + 'lint', + '--source', + 'sources/does-not-exist-*.md', + '--translation', + 'translations/%two_letters_code%/%original_file_name%', + ]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("No source files found for 'sources/does-not-exist-*.md' pattern"); + }); +}); diff --git a/tests/e2e/suites/glossary.test.ts b/tests/e2e/suites/glossary.test.ts new file mode 100644 index 000000000..8eab948fd --- /dev/null +++ b/tests/e2e/suites/glossary.test.ts @@ -0,0 +1,508 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { decode } from '@toon-format/toon'; +import AdmZip from 'adm-zip'; +import { findGlossaryId } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** Crowdin.com auto-creates a glossary named after every project. */ +function defaultGlossaryName(ctx: SuiteContext): string { + return `${ctx.project.name}'s Glossary`; +} + +/** + * Extracts the text content of every `...` occurrence in an XML string, guarding the + * tag-name boundary so e.g. `term` doesn't also match `termEntry`. + */ +function extractTagTexts(xml: string, tag: string): string[] { + const re = new RegExp(`<${tag}(?=[\\s>])[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g'); + return [...xml.matchAll(re)].map((m) => m[1] as string); +} + +/** + * Order-independent TBX content check: the server re-exports TBX in its own dialect (different + * termEntry ids, element ordering), so byte-equality against the source isn't meaningful - instead + * compare the sorted sets of `` and `` text content, which the roundtrip must preserve. + */ +async function extractTbxContent(path: string): Promise<{ terms: string[]; descriptions: string[] }> { + const xml = await Bun.file(path).text(); + return { + terms: extractTagTexts(xml, 'term').sort(), + descriptions: extractTagTexts(xml, 'descrip').sort(), + }; +} + +/** + * Order-independent XLSX content check. An xlsx is a zip container, so raw byte-equality isn't + * reliable (zip/docProps metadata differs run to run) - unzip with `adm-zip` and compare the sorted + * set of visible text runs from both the shared-strings table and the worksheet's own inline + * strings, covering either encoding a workbook writer may choose. + */ +function extractXlsxTexts(path: string): string[] { + const zip = new AdmZip(path); + const texts: string[] = []; + + for (const entryName of ['xl/sharedStrings.xml', 'xl/worksheets/sheet1.xml']) { + const entry = zip.getEntry(entryName); + + if (entry) { + texts.push(...extractTagTexts(entry.getData().toString('utf-8'), 't')); + } + } + + return texts.sort(); +} + +/** + * The three names the CLI derives from this suite's fixtures (`Created in Crowdin CLI ()`). + * + * Glossaries belong to the account, not to the project, so `teardownSuite` cannot reach them - and + * since the name comes from the uploaded file, a leftover from an interrupted run makes every + * `glossary upload` below fail with "The name '...' is already taken". They are swept both before + * the suite (self-healing) and after it. + */ +const SUITE_GLOSSARY_NAMES = ['simple-glossary.tbx', 'simple-glossary.csv', 'simple-glossary.xlsx'].map( + (file) => `Created in Crowdin CLI (${file})`, +); + +/** Far outside the account's id range, so `glossaryService.get` answers 404 rather than someone's. */ +const MISSING_GLOSSARY_ID = 999999999; + +/** + * This suite's own rows out of an account-wide listing, sorted by name. Takes either a decoded value + * or the raw json, so the two structured formats can be compared to each other. + */ +function suiteEntries(listing: string | unknown): { id: number; name: string; terms: number }[] { + const rows = (typeof listing === 'string' ? JSON.parse(listing) : listing) as { + id: number; + name: string; + terms: number; + }[]; + + return rows.filter((row) => SUITE_GLOSSARY_NAMES.includes(row.name)).sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Deletes every account glossary this suite owns by name. Never throws: cleanup must not mask a result. */ +async function removeSuiteGlossaries(ctx: SuiteContext): Promise { + try { + const response = await ctx.client.glossariesApi.withFetchAll().listGlossaries(); + + for (const entry of response.data) { + if (SUITE_GLOSSARY_NAMES.includes(entry.data.name)) { + await ctx.client.glossariesApi.deleteGlossary(entry.data.id); + } + } + } catch (error) { + console.warn(`Failed to clean up this suite's glossaries: ${error}`); + } +} + +describe('glossary', () => { + let ctx: SuiteContext; + let tbxGlossaryId: number; + let csvGlossaryId: number; + let xlsxGlossaryId: number; + + beforeAll(async () => { + ctx = await setupSuite('glossary'); + await removeSuiteGlossaries(ctx); + }); + + afterAll(async () => { + if (ctx && !ctx.env.keep) { + await removeSuiteGlossaries(ctx); + } + + await teardownSuite(ctx); + }); + + // `uploadAction` validates before it builds any service, so none of these reach the API. + test('rejects a file that does not exist', async () => { + const result = await ctx.runner.run(['glossary', 'upload', 'sources/missing.tbx', '--language', 'uk']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File 'sources/missing.tbx' not found in the Crowdin project"); + }); + + test('rejects a directory', async () => { + const result = await ctx.runner.run(['glossary', 'upload', 'sources', '--language', 'uk']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('The specified file is a directory'); + }); + + test('rejects an unsupported file extension', async () => { + const result = await ctx.runner.run(['glossary', 'upload', 'sources/unsupported.txt', '--language', 'uk']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Supported formats: tbx, csv, xlsx'); + }); + + // Unlike `tm upload`, which ignores a scheme it has no use for, glossary rejects it outright. + test('rejects a --scheme for a TBX file', async () => { + const result = await ctx.runner.run([ + 'glossary', + 'upload', + 'sources/simple-glossary.tbx', + '--language', + 'uk', + '--scheme', + 'term_en=1', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Scheme is used only for CSV or XLS/XLSX files'); + }); + + test('rejects a CSV without a scheme', async () => { + const result = await ctx.runner.run(['glossary', 'upload', 'sources/simple-glossary.csv', '--language', 'en']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Scheme is required for CSV or XLS/XLSX files'); + }); + + test('rejects a malformed --scheme value', async () => { + const result = await ctx.runner.run([ + 'glossary', + 'upload', + 'sources/simple-glossary.csv', + '--language', + 'en', + '--scheme', + 'term_en', + ]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("The '--scheme' parameter has an invalid value 'term_en'"); + }); + + test('rejects --first-line-contains-header for a TBX file', async () => { + const result = await ctx.runner.run([ + 'glossary', + 'upload', + 'sources/simple-glossary.tbx', + '--language', + 'uk', + '--first-line-contains-header', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--first-line-contains-header' is used only for CSV or XLS/XLSX files"); + }); + + test('requires --language when creating a new glossary', async () => { + const result = await ctx.runner.run(['glossary', 'upload', 'sources/simple-glossary.tbx']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--language' is required for creating new glossary"); + }); + + test('rejects a non-numeric --id on upload', async () => { + const result = await ctx.runner.run(['glossary', 'upload', 'sources/simple-glossary.tbx', '--id', 'not-a-number']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Glossary id must be numeric'); + }); + + test('uploads a TBX glossary, creating it', async () => { + const result = await ctx.runner.run(['glossary', 'upload', 'sources/simple-glossary.tbx', '--language', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Imported in #'); + expect(result.stdout).toContain("'Created in Crowdin CLI (simple-glossary.tbx)' glossary"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + tbxGlossaryId = await findGlossaryId(ctx, 'Created in Crowdin CLI (simple-glossary.tbx)'); + }); + + test('lists glossaries verbosely, including their terms', async () => { + const result = await ctx.runner.run(['glossary', 'list', '-v']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(defaultGlossaryName(ctx)); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-glossary.tbx)'); + // Spot-check one term/description pair from the uploaded TBX (see sources/simple-glossary.tbx). + expect(result.stdout).toContain('zuerst'); + expect(result.stdout).toContain('zuerst Beschreibung'); + // No snapshot: `glossary list` covers the whole account, so its output moves between runs. + }); + + test('uploads a CSV glossary with an explicit scheme, creating it', async () => { + const result = await ctx.runner.run([ + 'glossary', + 'upload', + 'sources/simple-glossary.csv', + '--language', + 'en', + '--scheme', + 'term_en=1', + '--scheme', + 'partOfSpeech_en=2', + '--scheme', + 'description_en=3', + '--scheme', + 'term_ar=4', + '--scheme', + 'description_ar=5', + '--scheme', + 'term_zh-CN=6', + '--scheme', + 'description_zh-CN=7', + '--scheme', + 'term_de=8', + '--scheme', + 'description_de=9', + '--scheme', + 'term_uk=10', + '--scheme', + 'description_uk=11', + '--first-line-contains-header', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("'Created in Crowdin CLI (simple-glossary.csv)' glossary"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + csvGlossaryId = await findGlossaryId(ctx, 'Created in Crowdin CLI (simple-glossary.csv)'); + }); + + test('uploads an XLSX glossary with an explicit scheme, creating it', async () => { + const result = await ctx.runner.run([ + 'glossary', + 'upload', + 'sources/simple-glossary.xlsx', + '--language', + 'uk', + '--scheme', + 'term_en=1', + '--scheme', + 'description_en=2', + '--scheme', + 'term_ar=3', + '--scheme', + 'description_ar=4', + '--scheme', + 'term_zh-CN=5', + '--scheme', + 'description_zh-CN=6', + '--scheme', + 'term_de=7', + '--scheme', + 'description_de=8', + '--scheme', + 'term_uk=9', + '--scheme', + 'description_uk=10', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("'Created in Crowdin CLI (simple-glossary.xlsx)' glossary"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + xlsxGlossaryId = await findGlossaryId(ctx, 'Created in Crowdin CLI (simple-glossary.xlsx)'); + }); + + test('lists all glossaries in the project', async () => { + const result = await ctx.runner.run(['glossary', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(defaultGlossaryName(ctx)); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-glossary.tbx)'); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-glossary.csv)'); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-glossary.xlsx)'); + }); + + test('serializes id, name and term count in the json listing', async () => { + const listed = await runJson<{ id: number; name: string; terms: number }[]>(ctx, ['glossary', 'list']); + const suite = listed.filter((glossary) => SUITE_GLOSSARY_NAMES.includes(glossary.name)); + + expect(suite.map((glossary) => glossary.name).sort()).toEqual([...SUITE_GLOSSARY_NAMES].sort()); + expect(suite.every((glossary) => Object.keys(glossary).join() === 'id,name,terms')).toBe(true); + expect(suite.every((glossary) => glossary.terms > 0)).toBe(true); + }); + + // Terms cost a request per glossary, so `-v` fetches them only for the text render. + test('ignores --verbose in the json listing', async () => { + const plainRun = await ctx.runner.run(['glossary', 'list', '--output', 'json']); + const verboseRun = await ctx.runner.run(['glossary', 'list', '--output', 'json', '-v']); + + expect(verboseRun).toMatchObject({ exitCode: 0 }); + // Two runs over an account-wide listing: another user's glossary must not read as a difference. + expect(suiteEntries(verboseRun.stdout)).toEqual(suiteEntries(plainRun.stdout)); + }); + + test('carries the same listing in the toon output', async () => { + const json = await ctx.runner.run(['glossary', 'list', '--output', 'json']); + const toon = await ctx.runner.run(['glossary', 'list', '--output', 'toon']); + + expect(toon).toMatchObject({ exitCode: 0 }); + expect(suiteEntries(decode(toon.stdout))).toEqual(suiteEntries(json.stdout)); + }); + + test('lists bare names in the plain output', async () => { + const result = await ctx.runner.run(['glossary', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const names = result.stdout.split('\n').filter((line) => line.length > 0); + + for (const name of SUITE_GLOSSARY_NAMES) { + expect(names).toContain(name); + } + }); + + // Like the upload guards, these fire before the glossary is fetched - hence the arbitrary id. + test('rejects a non-numeric glossary id', async () => { + const result = await ctx.runner.run(['glossary', 'download', 'not-a-number']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Glossary id must be numeric'); + }); + + test('rejects a --to extension that is not a supported format', async () => { + const result = await ctx.runner.run(['glossary', 'download', '1', '--to', 'download/out.txt']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Supported formats: tbx, csv, xlsx'); + }); + + test('reports a glossary that does not exist', async () => { + const result = await ctx.runner.run(['glossary', 'download', String(MISSING_GLOSSARY_ID)]); + + expect(result.exitCode).toBe(102); + expect(result.stderr).toContain('Not Found'); + }); + + test('downloads the TBX glossary by id and format', async () => { + const file = 'Created in Crowdin CLI (simple-glossary.tbx).tbx'; + + const result = await ctx.runner.run(['glossary', 'download', String(tbxGlossaryId), '--format', 'tbx']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Building glossary'); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const uploaded = await extractTbxContent(join(ctx.workspace, 'sources/simple-glossary.tbx')); + const downloaded = await extractTbxContent(join(ctx.workspace, file)); + expect(downloaded.terms).toEqual(uploaded.terms); + expect(downloaded.descriptions).toEqual(uploaded.descriptions); + }); + + test('downloads the CSV glossary by id and format', async () => { + const file = 'Created in Crowdin CLI (simple-glossary.csv).csv'; + + const result = await ctx.runner.run(['glossary', 'download', String(csvGlossaryId), '--format', 'csv']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Building glossary'); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await Bun.file(join(ctx.workspace, file)).text()).toBe( + await Bun.file(join(ctx.workspace, 'expected/simple-glossary.csv')).text(), + ); + }); + + test('downloads the XLSX glossary by id and format', async () => { + const file = 'Created in Crowdin CLI (simple-glossary.xlsx).xlsx'; + + const result = await ctx.runner.run(['glossary', 'download', String(xlsxGlossaryId), '--format', 'xlsx']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Building glossary'); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(extractXlsxTexts(join(ctx.workspace, file))).toEqual( + extractXlsxTexts(join(ctx.workspace, 'expected/simple-glossary.xlsx')), + ); + }); + + test('downloads the TBX glossary without an explicit format', async () => { + const file = 'Created in Crowdin CLI (simple-glossary.tbx).tbx'; + + const result = await ctx.runner.run(['glossary', 'download', String(tbxGlossaryId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test("downloads the project's default glossary by id", async () => { + const defaultGlossaryId = await findGlossaryId(ctx, defaultGlossaryName(ctx)); + const file = `${defaultGlossaryName(ctx)}.tbx`; + + const result = await ctx.runner.run(['glossary', 'download', String(defaultGlossaryId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('infers the format from the --to extension', async () => { + const file = 'download/inferred.csv'; + + const result = await ctx.runner.run(['glossary', 'download', String(csvGlossaryId), '--to', file]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + // Compared against the CSV baseline, not merely checked for existence: without the inference the + // export would default to TBX and still be written to this path. + expect(await Bun.file(join(ctx.workspace, file)).text()).toBe( + await Bun.file(join(ctx.workspace, 'expected/simple-glossary.csv')).text(), + ); + }); + + test('echoes the written path in the plain and json download output', async () => { + const file = 'download/echoed.tbx'; + const plain = await ctx.runner.run([ + 'glossary', + 'download', + String(tbxGlossaryId), + '--to', + file, + '--output', + 'plain', + ]); + + expect(plain).toMatchObject({ exitCode: 0 }); + expect(plain.stdout.trim()).toBe(file); + + expect(await runJson(ctx, ['glossary', 'download', String(tbxGlossaryId), '--to', file])).toBe(file); + }); + + // Last of the glossary-mutating tests: it imports into the TBX glossary the download tests read, + // so it has to run after them. + test('uploads into an existing glossary with --id', async () => { + const before = (await ctx.client.glossariesApi.getGlossary(tbxGlossaryId)).data.terms; + + // A separate fixture on purpose: re-importing `simple-glossary.tbx` would dedupe to the same + // terms and leave the count unable to move. + const imported = await runJson<{ id: number; name: string; terms: number }>(ctx, [ + 'glossary', + 'upload', + 'sources/extra-glossary.tbx', + '--id', + String(tbxGlossaryId), + ]); + + expect(imported.id).toBe(tbxGlossaryId); + expect(imported.name).toBe('Created in Crowdin CLI (simple-glossary.tbx)'); + // Refetched after the import: the copy taken before it still reports the original count. + expect(imported.terms).toBeGreaterThan(before); + }); + + test('lists glossaries authenticating via -T against a config without an api_token', async () => { + await switchConfig(ctx, 'without-token'); + + const result = await ctx.runner.run(['glossary', 'list', '-T', ctx.env.token as string]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(defaultGlossaryName(ctx)); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-glossary.tbx)'); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-glossary.csv)'); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-glossary.xlsx)'); + }); +}); diff --git a/tests/e2e/suites/identity.test.ts b/tests/e2e/suites/identity.test.ts new file mode 100644 index 000000000..cc5b514a5 --- /dev/null +++ b/tests/e2e/suites/identity.test.ts @@ -0,0 +1,59 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { expectFilesExist } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { renderFixture, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * The fixture's `crowdin.yml` carries no credentials at all: they come from `identity.yml`, passed + * via `--identity` and rendered with the real post-setup values. The identity file outranks the + * config file but not CLI flags. + */ +describe('identity file credentials', () => { + let ctx: SuiteContext; + let identityPath: string; + + beforeAll(async () => { + ctx = await setupSuite('identity', { targetLanguageIds: ['it', 'uk'] }); + identityPath = await renderFixture(ctx, 'identity.yml'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources using credentials from an --identity file', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--identity', identityPath]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations using credentials from an --identity file', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--identity', identityPath]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations using credentials from an --identity file', async () => { + const result = await ctx.runner.run(['download', 'translations', '--identity', identityPath]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'translations/it/android.xml', 'translations/uk/android.xml'); + }); + + test('validates the merged configuration via config lint --identity', async () => { + const result = await ctx.runner.run(['config', 'lint', '--identity', identityPath]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Your configuration file looks good'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/ignore.test.ts b/tests/e2e/suites/ignore.test.ts new file mode 100644 index 000000000..610feeabf --- /dev/null +++ b/tests/e2e/suites/ignore.test.ts @@ -0,0 +1,257 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { projectFilePaths } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Exercises the per-file `ignore:` config key against a fixed 15-file local tree (`ALL_FILES`) with + * source pattern `/**\/*.*`, plus the project-wide `ignore_hidden_files` setting against two hidden dotfiles that + * are deliberately NOT part of `ALL_FILES`. + * + * Row 1's pattern (`%file_name%-%two_letters_code%.%file_extension%`) relies on each candidate + * source file's own name/extension being substituted before matching, so a file that looks like + * another file's translation output gets excluded. + */ + +const ALL_FILES = [ + '/1.txt', + '/1.xml', + '/123.xml', + '/123_test.xml', + '/a.xml', + '/android-uk.xml', + '/android.xml', + '/folder/1.xml', + '/folder/123.xml', + '/folder/123_test.xml', + '/folder/a.xml', + '/folder/android-uk.xml', + '/folder/android.xml', + '/folder/sub/1.txt', + '/folder/sub/1.xml', +]; + +/** + * Deletes directories as well as files, so each row starts from a truly empty project: the last two + * tests assert `folder`/`folder/sub` being created, which needs them gone every time. + */ +async function resetProject(ctx: SuiteContext): Promise { + const files = await ctx.client.sourceFilesApi.listProjectFiles(ctx.project.id, { recursion: '1' }); + for (const file of files.data) { + await ctx.client.sourceFilesApi.deleteFile(ctx.project.id, file.data.id); + } + + const directories = await ctx.client.sourceFilesApi.listProjectDirectories(ctx.project.id, { recursion: '1' }); + const rootDirectories = directories.data.filter((directory) => !directory.data.directoryId); + for (const directory of rootDirectories) { + await ctx.client.sourceFilesApi.deleteDirectory(ctx.project.id, directory.data.id); + } +} + +describe('ignore', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('ignore', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('ignores files matching the translation-placeholder pattern (%file_name%-%two_letters_code%.%file_extension%)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/**/%file_name%-%two_letters_code%.%file_extension%'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = ['/android-uk.xml', '/folder/android-uk.xml']; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('ignores files matching a single-char wildcard (?.xml)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/**/?.xml'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = ['/1.xml', '/a.xml', '/folder/1.xml', '/folder/a.xml', '/folder/sub/1.xml']; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('ignores files matching a single-digit bracket class ([0-9].xml)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/**/[0-9].xml'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = ['/1.xml', '/folder/1.xml', '/folder/sub/1.xml']; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('ignores files matching a three-digit bracket class ([0-9][0-9][0-9].xml)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/**/[0-9][0-9][0-9].xml'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = ['/123.xml', '/folder/123.xml']; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('ignores files matching a digit-star-underscore bracket class ([0-9]*_*.xml)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/**/[0-9]*_*.xml'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = ['/123_test.xml', '/folder/123_test.xml']; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('combines two ignore patterns (?.xml and [0-9]*_*.xml)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/**/?.xml', '/**/[0-9]*_*.xml'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = [ + '/1.xml', + '/123_test.xml', + '/a.xml', + '/folder/1.xml', + '/folder/123_test.xml', + '/folder/a.xml', + '/folder/sub/1.xml', + ]; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('ignores a recursive glob scoped to a subfolder (/folder/**/*.xml)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/folder/**/*.xml'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = [ + '/folder/1.xml', + '/folder/123.xml', + '/folder/123_test.xml', + '/folder/a.xml', + '/folder/android-uk.xml', + '/folder/android.xml', + '/folder/sub/1.xml', + ]; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('ignores a recursive glob scoped to a subfolder, all extensions (/folder/**/*.*)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/folder/**/*.*'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = [ + '/folder/1.xml', + '/folder/123.xml', + '/folder/123_test.xml', + '/folder/a.xml', + '/folder/android-uk.xml', + '/folder/android.xml', + '/folder/sub/1.txt', + '/folder/sub/1.xml', + ]; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('ignores a bare folder name, excluding everything under it (/folder)', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore', { ignore: ['/folder'] }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const ignoredFiles = [ + '/folder/1.xml', + '/folder/123.xml', + '/folder/123_test.xml', + '/folder/a.xml', + '/folder/android-uk.xml', + '/folder/android.xml', + '/folder/sub/1.txt', + '/folder/sub/1.xml', + ]; + const expectedFiles = ALL_FILES.filter((file) => !ignoredFiles.includes(file)).sort(); + expect(await projectFilePaths(ctx)).toEqual(expectedFiles); + }); + + test('uploads hidden dotfiles when ignore_hidden_files is false', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore-hidden-files', { ignoreHiddenFiles: false }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain("Directory 'folder'"); + expect(result.stdout).toContain("Directory 'folder/sub'"); + expect(result.stdout).toContain("File 'folder/.hidden.xml'"); + expect(result.stdout).toContain("File 'folder/1.xml'"); + expect(result.stdout).toContain("File 'folder/123.xml'"); + expect(result.stdout).toContain("File 'folder/123_test.xml'"); + expect(result.stdout).toContain("File 'folder/a.xml'"); + expect(result.stdout).toContain("File 'folder/android-uk.xml'"); + expect(result.stdout).toContain("File 'folder/android.xml'"); + expect(result.stdout).toContain("File 'folder/sub/.hidden.xml'"); + expect(result.stdout).toContain("File 'folder/sub/1.txt'"); + expect(result.stdout).toContain("File 'folder/sub/1.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('skips hidden dotfiles when ignore_hidden_files is true', async () => { + await resetProject(ctx); + await switchConfig(ctx, 'ignore-hidden-files', { ignoreHiddenFiles: true }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain("Directory 'folder'"); + expect(result.stdout).toContain("Directory 'folder/sub'"); + expect(result.stdout).not.toContain('.hidden.xml'); + expect(result.stdout).toContain("File 'folder/1.xml'"); + expect(result.stdout).toContain("File 'folder/123.xml'"); + expect(result.stdout).toContain("File 'folder/123_test.xml'"); + expect(result.stdout).toContain("File 'folder/a.xml'"); + expect(result.stdout).toContain("File 'folder/android-uk.xml'"); + expect(result.stdout).toContain("File 'folder/android.xml'"); + expect(result.stdout).toContain("File 'folder/sub/1.txt'"); + expect(result.stdout).toContain("File 'folder/sub/1.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/init.test.ts b/tests/e2e/suites/init.test.ts new file mode 100644 index 000000000..ab7b9d489 --- /dev/null +++ b/tests/e2e/suites/init.test.ts @@ -0,0 +1,194 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { generate } from '@/lib/config/yamlGenerator.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +// `init --quiet` never talks to the API. +describe('init generates a configuration skeleton', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('init', { withoutProject: true }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('generates a configuration skeleton in quiet mode', async () => { + const destPath = join(ctx.workspace, 'crowdin.yaml'); + // normalize() masks the workspace root, so stdout assertions use the masked path. + const maskedDest = normalize(destPath); + + // `init` has no config to point `-c` at, and resolves a relative `-d` against cwd. + const result = await ctx.runner.run(['init', '--quiet', '-d', 'crowdin.yaml'], { noConfig: true }); + + expect(result).toMatchObject({ exitCode: 0 }); + + const stdout = normalize(result.stdout); + expect(stdout).toContain(`Generating Crowdin CLI configuration skeleton '${maskedDest}'`); + expect(stdout).toContain( + 'Your configuration skeleton has been successfully generated. Specify your source and translation paths in the files section. For more details see https://crowdin.github.io/crowdin-cli/configuration', + ); + expect(stdout).toMatchSnapshot(); + + // With no credentials passed, the generated file omits `api_token` entirely and keeps the real + // `base_url`, so it is compared against a freshly generated skeleton rather than a static fixture. + const expectedContent = generate({ + projectId: '', + apiToken: undefined, + basePath: '', + baseUrl: 'https://api.crowdin.com', + preserveHierarchy: true, + ignoreHiddenFiles: true, + files: [{ source: '', translation: '' }], + }); + + expect(await Bun.file(destPath).text()).toBe(expectedContent); + }); + + test('writes flag values into the skeleton in quiet mode', async () => { + const destPath = join(ctx.workspace, 'crowdin-full.yml'); + + const result = await ctx.runner.run( + [ + 'init', + '--quiet', + '-d', + 'crowdin-full.yml', + '--project-id', + '123', + '--token', + 'abc', + '--base-url', + 'https://acme.api.crowdin.com', + '--base-path', + 'src', + '--source', + 'src/**/*.json', + '--translation', + 'l10n/%locale%/%original_file_name%', + '--no-preserve-hierarchy', + ], + { noConfig: true }, + ); + + expect(result).toMatchObject({ exitCode: 0 }); + + const content = await Bun.file(destPath).text(); + const expectedContent = generate({ + projectId: 123, + apiToken: 'abc', + basePath: 'src', + baseUrl: 'https://acme.api.crowdin.com', + preserveHierarchy: false, + ignoreHiddenFiles: true, + files: [{ source: 'src/**/*.json', translation: 'l10n/%locale%/%original_file_name%' }], + }); + + expect(content).toBe(expectedContent); + expect(content).toContain('api_token'); + }); + + test('skips regeneration when the destination already exists', async () => { + const destPath = join(ctx.workspace, 'crowdin.yaml'); + const maskedDest = normalize(destPath); + const contentBefore = await Bun.file(destPath).text(); + + const result = await ctx.runner.run(['init', '--quiet', '-d', 'crowdin.yaml'], { noConfig: true }); + + expect(result).toMatchObject({ exitCode: 0 }); + + const stdout = normalize(result.stdout); + expect(stdout).toContain(`Generating Crowdin CLI configuration skeleton '${maskedDest}'`); + expect(stdout).toContain( + `File '${maskedDest}' already exists. Fill it out accordingly to the following requirements: ` + + 'https://developer.crowdin.com/configuration-file/#configuration-file-structure', + ); + expect(stdout).toMatchSnapshot(); + + expect(await Bun.file(destPath).text()).toBe(contentBefore); + }); + + test('lints the generated (incomplete) skeleton', async () => { + // Points --config at the previous test's skeleton; the auto-appended `-c` would override it. + const result = await ctx.runner.run(['config', 'lint', '--config', 'crowdin.yaml'], { noConfig: true }); + + expect(result.exitCode).toBe(2); + + // Lint failures are diagnostics, so the whole report is on stderr and stdout stays empty. + const stderr = normalize(result.stderr); + expect(stderr).toContain('Configuration file is invalid.'); + expect(stderr).toContain('source parameter cannot be empty'); + expect(stderr).toContain('translation parameter cannot be empty'); + // The empty `project_id` fails as a zod range error, + // and `api_token` is absent from the skeleton entirely - left to the snapshot, since the + // remaining wording is zod's own. + expect(stderr).toMatchSnapshot(); + }); + + test('creates missing parent directories for a nested destination', async () => { + // The parent directory does not exist: init relies on Bun.write creating it. + const destPath = join(ctx.workspace, 'nested', 'sub', 'crowdin.yml'); + const result = await ctx.runner.run(['init', '--quiet', '-d', 'nested/sub/crowdin.yml'], { noConfig: true }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(await Bun.file(destPath).exists()).toBe(true); + }); + + test('writes to crowdin.yml when no destination is given', async () => { + // Run from a subdirectory: the suite's own rendered config already occupies + // /crowdin.yml, which is the very path the default resolves to. + const directory = join(ctx.workspace, 'default-destination'); + await mkdir(directory, { recursive: true }); + + const result = await ctx.runner.run(['init', '--quiet'], { noConfig: true, cwd: directory }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(await Bun.file(join(directory, 'crowdin.yml')).exists()).toBe(true); + }); + + test('generates a skeleton that lints clean once the paths are filled in', async () => { + const init = await ctx.runner.run( + [ + 'init', + '--quiet', + '-d', + 'lintable.yml', + '--project-id', + '123', + '--token', + 'a'.repeat(80), + '--base-path', + '.', + '--source', + '/sources/*.json', + '--translation', + '/l10n/%locale%/%original_file_name%', + ], + { noConfig: true }, + ); + + expect(init).toMatchObject({ exitCode: 0 }); + + const result = await ctx.runner.run(['config', 'lint', '--config', 'lintable.yml'], { noConfig: true }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Your configuration file looks good'); + }); + + test('writes the file but no stdout under a machine --output', async () => { + const destPath = join(ctx.workspace, 'machine-output.yml'); + const result = await ctx.runner.run(['init', '--quiet', '-d', 'machine-output.yml', '--output', 'json'], { + noConfig: true, + }); + + expect(result).toMatchObject({ exitCode: 0 }); + // init reports through intro/outro, which are text-only, so the file on disk is the whole + // result - a consumer scripting `init` gets an empty document, not a record. + expect(result.stdout.trim()).toBe(''); + expect(await Bun.file(destPath).exists()).toBe(true); + }); +}); diff --git a/tests/e2e/suites/invalid-credentials.test.ts b/tests/e2e/suites/invalid-credentials.test.ts new file mode 100644 index 000000000..ce0b82b4e --- /dev/null +++ b/tests/e2e/suites/invalid-credentials.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +// One config broken in one place per test. A non-existent organization is not covered - it only +// applies to Enterprise, and this harness only ever talks to plain crowdin.com. +describe('invalid credentials', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('invalid-credentials'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('rejects a non-numeric project_id', async () => { + // A leading-space project_id (' 999999') would be trimmed and accepted by `z.coerce.number()`, so + // the value here is genuinely non-numeric. + await switchConfig(ctx, 'invalid-project-id'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("Option 'project_id' must be a numeric value"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports a project that does not exist', async () => { + await switchConfig(ctx, 'nonexistent-project-id'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result.exitCode).toBe(102); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stderr).toContain('Not Found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports an invalid api_token', async () => { + // 401 is the one API status `mapCrowdinError` answers with a fixed message of its own, so the + // wording can be asserted exactly. + await switchConfig(ctx, 'invalid-token'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result.exitCode).toBe(101); + expect(result.stderr).toContain("Couldn't authorize. Check your 'api_token'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects a base_path that does not exist', async () => { + await switchConfig(ctx, 'nonexistent-base-path'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Configuration file is invalid. Check the following parameters'); + expect(result.stderr).toContain( + "The base path '/not/exists/path' was not found. Check your 'base_path' for possible typos and/or capitalization mismatches", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects an invalid base_url', async () => { + // The wording is the config schema's own. + await switchConfig(ctx, 'invalid-base-url'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + 'base_url must be a Crowdin URL (e.g. https://api.crowdin.com or https://.crowdin.com)', + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/invalid-files-config.test.ts b/tests/e2e/suites/invalid-files-config.test.ts new file mode 100644 index 000000000..5ae600a72 --- /dev/null +++ b/tests/e2e/suites/invalid-files-config.test.ts @@ -0,0 +1,115 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * One fixture with a valid fallback `files:` entry serves every test, because a CLI `--source`/`--translation` pair replaces `config.files` outright. + */ +describe('invalid files config', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('invalid-files-config'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources with a source pattern that matches no local file', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--source', + '/sources/android-not-exists.xml', + '--translation', + '/translations/%two_letters_code%/android.xml', + ]); + + // A zero-match group flags the run; both lines land on stderr. + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stderr).toContain( + "No sources found for '/sources/android-not-exists.xml' pattern. Check the source paths in your configuration file", + ); + expect(result.stderr).toContain('Current execution finished with errors'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources with a source pattern whose folder does not exist', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--source', + '/not-exists/**/*.*', + '--translation', + '/translations/%two_letters_code%/android.xml', + ]); + + // A nonexistent base folder globs to zero matches too. + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stderr).toContain( + "No sources found for '/not-exists/**/*.*' pattern. Check the source paths in your configuration file", + ); + expect(result.stderr).toContain('Current execution finished with errors'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources with a translation pattern missing a language placeholder', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--source', + '/sources/android.xml', + // Capital L: not a recognized placeholder, so the pattern counts as having none. + '--translation', + '/translations/%two_Letters_code%/android.xml', + ]); + + // The config schema rejects this before any API call. + expect(result.exitCode).toBe(2); + expect(result.stdout).not.toContain('Fetching project info'); + expect(result.stderr).toContain( + "The 'translation' parameter should contain at least one language placeholder (e.g. %locale%)", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources with a translation pattern containing a relative path', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--source', + '/sources/android.xml', + '--translation', + '../translations/%two_letters_code%/android.xml', + ]); + + // Same gate, different rule: the translation field rejects `../`. + expect(result.exitCode).toBe(2); + expect(result.stdout).not.toContain('Fetching project info'); + expect(result.stderr).toContain("The 'translation' parameter can't contain any relative paths '../' or './'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations when the source file does not exist in the project', async () => { + const result = await ctx.runner.run([ + 'upload', + 'translations', + '--source', + '/sources/android.xml', + '--translation', + '/translations/%two_letters_code%/android-not-exists.xml', + ]); + + // No earlier test in this suite uploads anything, so the project is still empty: the run fails + // on the missing SOURCE file and never reaches the nonexistent translation filename. One error + // line per source file, not per target language. + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stderr).toContain("Source file 'sources/android.xml' does not exist in the project"); + expect(result.stderr).toContain('Current execution finished with errors'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/label.test.ts b/tests/e2e/suites/label.test.ts new file mode 100644 index 000000000..964c4b969 --- /dev/null +++ b/tests/e2e/suites/label.test.ts @@ -0,0 +1,201 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { decode } from '@toon-format/toon'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `label list` / `label add` / `label delete` (`cli/commands/label/LabelCommand.ts`). + * + * Server label ids are neither contiguous nor stable between runs, so nothing asserts one: json + * matches titles, and the text listings go through `normalize`, which masks `#123` to `#id`. + * + * The last test covers how labels are really created - `LabelService.resolveLabelIds` with + * `createMissing`, reached from `upload sources --label`. + */ + +interface ListedLabel { + id: number; + title: string; +} + +describe('label', () => { + let ctx: SuiteContext; + + async function listTitles(): Promise { + return (await runJson(ctx, ['label', 'list'])).map((label) => label.title).sort(); + } + + beforeAll(async () => { + ctx = await setupSuite('label', { targetLanguageIds: ['uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['label']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage labels'); + expect(result.stdout).toContain('add '); + expect(result.stdout).toContain('delete <title>'); + }); + + test('colors help unless --no-colors is passed', async () => { + const result = await ctx.runner.run(['label'], { colors: true }); + + expect(result).toMatchObject({ exitCode: 0 }); + // Bold title, cyan program name. + expect(result.stdout).toContain('\u001b[1mUsage:\u001b[22m \u001b[36mcrowdin\u001b[39m'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['label', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test.each(['json', 'toon'] as const)( + 'reports a usage error as a %s record carrying the exit code', + async (format) => { + const result = await ctx.runner.run(['label', 'bogus', '--output', format]); + const parse = format === 'json' ? JSON.parse : decode; + + expect(result.exitCode).toBe(2); + // commander's own prose is suppressed; the top-level handler re-emits it as a record instead. + expect(parse(result.stderr)).toEqual({ level: 'error', message: "unknown command 'bogus'", code: 2 }); + }, + ); + + test('reports an empty project', async () => { + const result = await ctx.runner.run(['label', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('No labels found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test.each(['json', 'toon'] as const)('reports an empty project as an empty %s list', async (format) => { + const result = await ctx.runner.run(['label', 'list', '--output', format]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(format === 'json' ? JSON.parse(result.stdout) : decode(result.stdout)).toEqual([]); + }); + + test('requires a title to add', async () => { + const result = await ctx.runner.run(['label', 'add']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'title'"); + }); + + test('requires a title to delete', async () => { + const result = await ctx.runner.run(['label', 'delete']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'title'"); + }); + + test('adds a label and echoes it back', async () => { + // Out of alphabetical order on purpose, so a listing that depends on insertion order fails. + const result = await ctx.runner.run(['label', 'add', 'zebra-label']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('zebra-label'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await listTitles()).toEqual(['zebra-label']); + }); + + test('warns instead of duplicating when the title already exists', async () => { + const result = await ctx.runner.run(['label', 'add', 'zebra-label']); + + // `addAction` returns after warning rather than throwing, hence the success exit. + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("Label 'zebra-label' already exists in the project"); + expect(await listTitles()).toEqual(['zebra-label']); + }); + + test('adds a second label', async () => { + const result = await ctx.runner.run(['label', 'add', 'alpha-label']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(await listTitles()).toEqual(['alpha-label', 'zebra-label']); + }); + + test('lists both labels with their ids', async () => { + const result = await ctx.runner.run(['label', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists the titles alone with --output plain', async () => { + const result = await ctx.runner.run(['label', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect( + result.stdout + .split('\n') + .filter((line) => line.length > 0) + .sort(), + ).toEqual(['alpha-label', 'zebra-label']); + }); + + test('carries the ids into a verbose plain listing', async () => { + // `labelVerboseView` points `plain` at the text renderer. + const result = await ctx.runner.run(['label', 'list', '--output', 'plain', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const lines = result.stdout.split('\n').filter((line) => line.length > 0); + + // Sorted by title, not whole line: the id comes first, so `#27 zebra` sorts before `#29 alpha`. + expect(lines.map((line) => line.replace(/^#\d+ /, '')).sort()).toEqual(['alpha-label', 'zebra-label']); + + for (const line of lines) { + expect(line).toMatch(/^#\d+ \S+$/); + } + }); + + test('serializes id and title in a structured format', async () => { + const labels = (await runJson<ListedLabel[]>(ctx, ['label', 'list'])).sort((left, right) => + left.title < right.title ? -1 : 1, + ); + + expect(labels).toEqual([ + { id: expect.any(Number), title: 'alpha-label' }, + { id: expect.any(Number), title: 'zebra-label' }, + ]); + }); + + test('rejects deleting a title the project does not have', async () => { + const result = await ctx.runner.run(['label', 'delete', 'nope']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Couldn't find label by the specified title"); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test('deletes a label by title', async () => { + const result = await ctx.runner.run(['label', 'delete', 'zebra-label']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Label 'zebra-label' deleted successfully"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await listTitles()).toEqual(['alpha-label']); + }); + + test('shows a label created on the fly by `upload sources --label`', async () => { + // resolveLabelIds creates any title the project lacks, so the upload mints 'from-upload'. + const upload = await ctx.runner.run(['upload', 'sources', '--label', 'from-upload']); + + expect(upload).toMatchObject({ exitCode: 0 }); + expect(upload.stdout).toContain("File 'sources/1_android.xml'"); + + expect(await listTitles()).toEqual(['alpha-label', 'from-upload']); + }); +}); diff --git a/tests/e2e/suites/language-mapping.test.ts b/tests/e2e/suites/language-mapping.test.ts new file mode 100644 index 000000000..0101d027d --- /dev/null +++ b/tests/e2e/suites/language-mapping.test.ts @@ -0,0 +1,239 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import type { ProjectsGroupsModel } from '@crowdin/crowdin-api-client'; +import { captureAndClear, expectRestored } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Each of the 8 file + * groups below exercises one language placeholder with a per-file `languages_mapping` override for + * `uk`/`zh-CN`. A local `languages_mapping` wins over the project's server-side mapping, which wins + * over the language's default code. + * + * The second half switches to `alt-configs/crowdin-no-mapping.yml` (same 8 groups, no local + * `languages_mapping`) after setting the project's server-side language mapping via + * `editProject`/`languageMapping`. That mapping mirrors every local override except + * `android_code`, which is deliberately set to a different value (`*_crwd`) to prove the server + * mapping - not a stale local one - is what takes effect once the local override is removed. + */ + +const UK_LANGUAGE_MAPPING: ProjectsGroupsModel.LanguageMappingEntity = { + name: 'Ukrainian_', + android_code: 'uk-rUA_crwd', + two_letters_code: 'uk_', + three_letters_code: 'ukr_', + locale: 'uk-UA_', + locale_with_underscore: 'uk_UA_', + osx_code: 'uk.lproj_', + osx_locale: 'uk_', +}; + +const ZH_CN_LANGUAGE_MAPPING: ProjectsGroupsModel.LanguageMappingEntity = { + name: 'Chinese Simplified_', + android_code: 'zh-rCN_crwd', + two_letters_code: 'zh_', + three_letters_code: 'zho_', + locale: 'zh-CN_', + locale_with_underscore: 'zh_CN_', + osx_code: 'zh-Hans.lproj_', + osx_locale: 'zh-Hans_', +}; + +describe('language mapping', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('language-mapping', { targetLanguageIds: ['uk', 'zh-CN'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources for every language-mapping placeholder', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + for (const group of [ + 'android_code', + 'language', + 'locale', + 'locale_with_underscore', + 'osx_code', + 'osx_locale', + 'three_letters_code', + 'two_letters_code', + ]) { + expect(result.stdout).toContain(`Directory '${group}'`); + expect(result.stdout).toContain(`File '${group}/android.xml'`); + } + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation upload with the default language mapping', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + for (const path of [ + 'android_code/uk-rUA_/android.xml', + 'android_code/zh-rCN_/android.xml', + 'language/Ukrainian_/android.xml', + 'language/Chinese Simplified_/android.xml', + 'locale/uk-UA_/android.xml', + 'locale/zh-CN_/android.xml', + 'locale_with_underscore/uk_UA_/android.xml', + 'locale_with_underscore/zh_CN_/android.xml', + 'osx_code/uk.lproj_/android.xml', + 'osx_code/zh-Hans.lproj_/android.xml', + 'osx_locale/uk_/android.xml', + 'osx_locale/zh-Hans_/android.xml', + 'three_letters_code/ukr_/android.xml', + 'three_letters_code/zho_/android.xml', + 'two_letters_code/uk_/android.xml', + 'two_letters_code/zh_/android.xml', + ]) { + expect(result.stdout).toContain(`File '${path}' would be queued for translations import`); + } + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations with the default language mapping', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'android_code/uk-rUA_/android.xml'"); + expect(result.stdout).toContain("File 'android_code/uk-rUA_/android.xml'"); + expect(result.stdout).toContain("File 'locale/zh-CN_/android.xml'"); + expect(result.stdout).toContain("File 'osx_locale/uk_/android.xml'"); + expect(result.stdout).toContain("File 'two_letters_code/zh_/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation download with the default language mapping', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('android_code/uk-rUA_/android.xml'); + expect(result.stdout).toContain('locale/zh-CN_/android.xml'); + expect(result.stdout).toContain('three_letters_code/ukr_/android.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations with the default language mapping', async () => { + // Every mapped path below already holds an upload fixture, so clear them first - an existence + // check against a file that was never removed proves nothing about the download. + const captured = await captureAndClear( + ctx.workspace, + 'android_code/uk-rUA_/android.xml', + 'android_code/zh-rCN_/android.xml', + 'language/Ukrainian_/android.xml', + 'language/Chinese Simplified_/android.xml', + 'locale/uk-UA_/android.xml', + 'locale/zh-CN_/android.xml', + 'locale_with_underscore/uk_UA_/android.xml', + 'locale_with_underscore/zh_CN_/android.xml', + 'osx_code/uk.lproj_/android.xml', + 'osx_code/zh-Hans.lproj_/android.xml', + 'osx_locale/uk_/android.xml', + 'osx_locale/zh-Hans_/android.xml', + 'three_letters_code/ukr_/android.xml', + 'three_letters_code/zho_/android.xml', + 'two_letters_code/uk_/android.xml', + 'two_letters_code/zh_/android.xml', + ); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android_code/uk-rUA_/android.xml' extracted"); + expect(result.stdout).toContain("File 'language/Chinese Simplified_/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectRestored(ctx.workspace, captured); + }); + + test('sets a server-side language mapping and previews the upload without local overrides', async () => { + await ctx.client.projectsGroupsApi.editProject(ctx.project.id, [ + { + op: 'replace', + path: '/languageMapping', + value: { uk: UK_LANGUAGE_MAPPING, 'zh-CN': ZH_CN_LANGUAGE_MAPPING }, + }, + ]); + await switchConfig(ctx, 'crowdin-no-mapping'); + + const result = await ctx.runner.run(['upload', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + // android_code now resolves from the server mapping (*_crwd), not the removed local override. + expect(result.stdout).toContain( + "File 'android_code/uk-rUA_crwd/android.xml' would be queued for translations import", + ); + expect(result.stdout).toContain( + "File 'android_code/zh-rCN_crwd/android.xml' would be queued for translations import", + ); + // Every other group's server value mirrors its old local override, so the path is unchanged. + expect(result.stdout).toContain("File 'locale/uk-UA_/android.xml' would be queued for translations import"); + expect(result.stdout).toContain( + "File 'three_letters_code/zho_/android.xml' would be queued for translations import", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists target languages using the android_code mapping', async () => { + const result = await ctx.runner.run(['language', 'list', '--code', 'android_code']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('uk-rUA_crwd'); + expect(result.stdout).toContain('zh-rCN_crwd'); + expect(result.stdout).toContain('Ukrainian'); + expect(result.stdout).toContain('Chinese Simplified'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists target languages using the three_letters_code mapping', async () => { + const result = await ctx.runner.run(['language', 'list', '--code', 'three_letters_code']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('ukr_'); + expect(result.stdout).toContain('zho_'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations under the server-side language mapping', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'android_code/uk-rUA_crwd/android.xml'"); + expect(result.stdout).toContain("File 'android_code/uk-rUA_crwd/android.xml'"); + expect(result.stdout).toContain("File 'android_code/zh-rCN_crwd/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation download under the server-side language mapping', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('android_code/uk-rUA_crwd/android.xml'); + expect(result.stdout).toContain('android_code/zh-rCN_crwd/android.xml'); + expect(result.stdout).toContain('locale/uk-UA_/android.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations under the server-side language mapping', async () => { + const captured = await captureAndClear( + ctx.workspace, + 'android_code/uk-rUA_crwd/android.xml', + 'android_code/zh-rCN_crwd/android.xml', + ); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android_code/uk-rUA_crwd/android.xml' extracted"); + expect(result.stdout).toContain("File 'android_code/zh-rCN_crwd/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectRestored(ctx.workspace, captured); + }); +}); diff --git a/tests/e2e/suites/language.test.ts b/tests/e2e/suites/language.test.ts new file mode 100644 index 000000000..87d41652c --- /dev/null +++ b/tests/e2e/suites/language.test.ts @@ -0,0 +1,163 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { decode } from '@toon-format/toon'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `language list` (`cli/commands/language/LanguageCommand.ts`): the `--code` matrix and the + * three credential paths `--all` takes - a project's languages, the account's supported languages with a token but no project, and + * the public list with no credentials at all - the only unauthenticated call the CLI makes. + * + * The mapping precedence in `getCode` belongs to `language-mapping.test.ts`, which sets a project + * mapping up; this suite reads the plain language codes the API hands back. + * + * `--all` returns Crowdin's whole supported-language list, which grows as Crowdin adds languages - + * so nothing here snapshots it or asserts its length exactly. + */ +const TARGET_LANGUAGES = ['it', 'uk']; +/** A language no project in this suite targets, so it can only come from the supported list. */ +const UNTARGETED_LANGUAGE = 'de'; + +describe('language', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('language', { targetLanguageIds: TARGET_LANGUAGES }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + async function listedCodes(args: string[] = []): Promise<string[]> { + return (await runJson<{ code: string }[]>(ctx, ['language', 'list', ...args])) + .map((language) => language.code) + .sort(); + } + + async function apiCodes(languageId: string): Promise<Record<string, string>> { + const { data } = await ctx.client.languagesApi.getLanguage(languageId); + + return { + id: data.id, + two_letters_code: data.twoLettersCode, + three_letters_code: data.threeLettersCode, + locale: data.locale, + android_code: data.androidCode, + osx_code: data.osxCode, + osx_locale: data.osxLocale, + }; + } + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['language']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage languages'); + expect(result.stdout).toContain('list'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['language', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('lists the target languages of the project', async () => { + const result = await ctx.runner.run(['language', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Italian'); + expect(result.stdout).toContain('Ukrainian'); + expect(normalize(result.stdout)).toMatchSnapshot(); + expect(await listedCodes()).toEqual(TARGET_LANGUAGES); + }); + + test('lists bare codes in the plain output', async () => { + const result = await ctx.runner.run(['language', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout.trim().split('\n').sort()).toEqual(TARGET_LANGUAGES); + }); + + test('carries the code and the name in the json output', async () => { + const languages = await runJson<{ code: string; name: string }[]>(ctx, ['language', 'list']); + + expect(languages.map(({ code, name }) => ({ code, name })).sort((a, b) => a.code.localeCompare(b.code))).toEqual([ + { code: 'it', name: 'Italian' }, + { code: 'uk', name: 'Ukrainian' }, + ]); + }); + + test('carries the same list in the toon output as in the json one', async () => { + const toon = await ctx.runner.run(['language', 'list', '--output', 'toon']); + + expect(toon).toMatchObject({ exitCode: 0 }); + expect(await runJson(ctx, ['language', 'list'])).toEqual(decode(toon.stdout)); + }); + + test('renders every supported --code format', async () => { + const expected = await apiCodes('uk'); + + for (const format of [ + 'id', + 'two_letters_code', + 'three_letters_code', + 'locale', + 'android_code', + 'osx_code', + 'osx_locale', + ]) { + const codes = await listedCodes(['--code', format]); + + expect(codes).toContain(expected[format] as string); + } + }); + + test('rejects an unsupported --code value', async () => { + const result = await ctx.runner.run(['language', 'list', '--code', 'bogus_code']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('bogus_code'); + }); + + test('lists the supported languages of the account with --all', async () => { + const codes = await listedCodes(['--all']); + + expect(codes).toContain(UNTARGETED_LANGUAGE); + expect(codes.length).toBeGreaterThan(TARGET_LANGUAGES.length); + }); + + test('lists the supported languages with a token but no project', async () => { + await switchConfig(ctx, 'no-project'); + + const codes = await listedCodes(['--all']); + + expect(codes).toContain(UNTARGETED_LANGUAGE); + expect(codes).toContain('uk'); + }); + + test('still needs a project without --all', async () => { + const result = await ctx.runner.run(['language', 'list']); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('project_id'); + }); + + test('lists the supported languages with no credentials at all', async () => { + await switchConfig(ctx, 'no-credentials'); + + const codes = await listedCodes(['--all']); + + expect(codes).toContain(UNTARGETED_LANGUAGE); + expect(codes).toContain('uk'); + }); + + test('applies --code to the public list as well', async () => { + const expected = await apiCodes('uk'); + const codes = await listedCodes(['--all', '--code', 'three_letters_code']); + + expect(codes).toContain(expected.three_letters_code as string); + }); +}); diff --git a/tests/e2e/suites/multilingual-csv-with-language-placeholder.test.ts b/tests/e2e/suites/multilingual-csv-with-language-placeholder.test.ts new file mode 100644 index 000000000..f94b2d99a --- /dev/null +++ b/tests/e2e/suites/multilingual-csv-with-language-placeholder.test.ts @@ -0,0 +1,181 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * `download translations` writes to the exact local path the `upload translations` fixtures + * already occupy (`translations/<lang>/<file>`), and a stale file left over from an earlier + * upload/download in this suite could masquerade as a successful download. Clear it first. + */ +async function clearDownloadedTranslations(ctx: SuiteContext): Promise<void> { + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); +} + +describe('multilingual csv with language placeholder', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('multilingual-csv-with-language-placeholder', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads multilingual CSV sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain("File 'sources/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'sources/2_multilingual.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for every target language', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'translations/it/1_multilingual.csv'"); + expect(result.stdout).toContain("Importing translations for file 'translations/it/2_multilingual.csv'"); + expect(result.stdout).toContain("Importing translations for file 'translations/uk/1_multilingual.csv'"); + expect(result.stdout).toContain("Importing translations for file 'translations/uk/2_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/it/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/it/2_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/uk/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/uk/2_multilingual.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for a single language via --language', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--language', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'translations/uk/1_multilingual.csv'"); + expect(result.stdout).toContain("Importing translations for file 'translations/uk/2_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/uk/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/uk/2_multilingual.csv'"); + expect(result.stdout).not.toContain("Importing translations for file 'translations/it/1_multilingual.csv'"); + expect(result.stdout).not.toContain("Importing translations for file 'translations/it/2_multilingual.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation download (dryrun)', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('translations/it/1_multilingual.csv'); + expect(result.stdout).toContain('translations/it/2_multilingual.csv'); + expect(result.stdout).toContain('translations/uk/1_multilingual.csv'); + expect(result.stdout).toContain('translations/uk/2_multilingual.csv'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations and matches the merged multilingual content', async () => { + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/1_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations/it/2_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations/uk/1_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations/uk/2_multilingual.csv' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected', + 'it/1_multilingual.csv', + 'it/2_multilingual.csv', + 'uk/1_multilingual.csv', + 'uk/2_multilingual.csv', + ); + }); + + test('updates sources from a new base path, targeting a new translation destination', async () => { + await switchConfig(ctx, 'crowdin-v2'); + + const result = await ctx.runner.run(['upload', 'sources', '--base-path', 'rev2']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'sources/2_multilingual.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations at the new translations-v2 destination', async () => { + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations-v2/it/1_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations-v2/it/2_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations-v2/uk/1_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations-v2/uk/2_multilingual.csv' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources to a new branch', async () => { + await switchConfig(ctx, 'crowdin-original'); + + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain("File 'sources/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'sources/2_multilingual.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates sources on the branch (branch already exists)', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'sources/2_multilingual.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations on the branch', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'translations/it/1_multilingual.csv'"); + expect(result.stdout).toContain("Importing translations for file 'translations/it/2_multilingual.csv'"); + expect(result.stdout).toContain("Importing translations for file 'translations/uk/1_multilingual.csv'"); + expect(result.stdout).toContain("Importing translations for file 'translations/uk/2_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/it/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/it/2_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/uk/1_multilingual.csv'"); + expect(result.stdout).toContain("File 'translations/uk/2_multilingual.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + // Depends on the two branch upload tests above actually having pushed content server-side. + test('downloads translations on the branch', async () => { + await clearDownloadedTranslations(ctx); + + const result = await ctx.runner.run(['download', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/1_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations/it/2_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations/uk/1_multilingual.csv' extracted"); + expect(result.stdout).toContain("File 'translations/uk/2_multilingual.csv' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'translations', + 'expected', + 'it/1_multilingual.csv', + 'it/2_multilingual.csv', + 'uk/1_multilingual.csv', + 'uk/2_multilingual.csv', + ); + }); +}); diff --git a/tests/e2e/suites/multilingual-csv.test.ts b/tests/e2e/suites/multilingual-csv.test.ts new file mode 100644 index 000000000..ea213a3b2 --- /dev/null +++ b/tests/e2e/suites/multilingual-csv.test.ts @@ -0,0 +1,212 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { expectFilesExist, expectFilesMatch } from '../helpers/files.ts'; +import { findFileId } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +describe('multilingual csv', () => { + let ctx: SuiteContext; + let fileId: number; + + beforeAll(async () => { + ctx = await setupSuite('multilingual-csv', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('upload with translations import', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'with-translations'"); + expect(result.stdout).toContain("File 'with-translations/sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + fileId = await findFileId(ctx, '/with-translations/sample.csv'); + + expect(await translationsFor('uk', fileId)).toEqual([ + 'стрічка 1', + 'стрічка 2', + 'стрічка 3', + 'стрічка 4', + 'стрічка 5', + ]); + expect(await translationsFor('it', fileId)).toEqual([ + 'stringa 1', + 'stringa 2', + 'stringa 3', + 'stringa 4', + 'stringa 5', + ]); + }); + + test('update with translations import', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--base-path', 'sources/rev2']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'with-translations/sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await translationsFor('uk', fileId)).toContainValues(['стрічка 6', 'стрічка 7']); + expect(await translationsFor('it', fileId)).toContainValues(['stringa 6', 'stringa 7']); + }); + + test('download file with translations (dryrun)', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('with-translations/sample.csv'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('download file with translations', async () => { + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('with-translations/sample.csv'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'sources/rev1/with-translations/sample.csv'); + + // syntax in downloaded file is different from source, so we know the file was downloaded + await expectFilesMatch(ctx.workspace, 'sources/rev1', 'expected', 'with-translations/sample.csv'); + }); + + test('upload without translations import', async () => { + await switchConfig(ctx, 'without-translations'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'without-translations'"); + expect(result.stdout).toContain("File 'without-translations/sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + fileId = await findFileId(ctx, '/without-translations/sample.csv'); + + expect(await translationsFor('uk', fileId)).toBeEmpty(); + expect(await translationsFor('it', fileId)).toBeEmpty(); + }); + + test('update without translations import', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--base-path', 'sources/rev2']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'without-translations/sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await translationsFor('uk', fileId)).toBeEmpty(); + expect(await translationsFor('it', fileId)).toBeEmpty(); + }); + + test('upload translations for single language', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--language', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'without-translations/sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await translationsFor('uk', fileId)).toEqual([ + 'стрічка 1', + 'стрічка 2', + 'стрічка 3', + 'стрічка 4', + 'стрічка 5', + ]); + }); + + test('upload translations for all languages', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'without-translations/sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await translationsFor('uk', fileId)).toEqual([ + 'стрічка 1', + 'стрічка 2', + 'стрічка 3', + 'стрічка 4', + 'стрічка 5', + ]); + expect(await translationsFor('it', fileId)).toEqual([ + 'stringa 1', + 'stringa 2', + 'stringa 3', + 'stringa 4', + 'stringa 5', + ]); + }); + + test('upload sources to a brand-new branch', async () => { + await switchConfig(ctx, 'branch'); + + const result = await ctx.runner.run(['upload', 'sources', '--branch', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('update sources in existing branch', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--branch', + 'test-branch', + '--base-path', + 'sources/rev2/branch', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('upload translations to the branch', async () => { + const result = await ctx.runner.run([ + 'upload', + 'translations', + '--branch', + 'test-branch', + '--base-path', + 'sources/rev3/branch', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'sample.csv'"); + expect(result.stdout).toContain("File 'sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('download translations from branch', async () => { + const result = await ctx.runner.run(['download', 'translations', '--branch', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sample.csv' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'sources/rev1/branch/sample.csv'); + + await expectFilesMatch(ctx.workspace, 'sources/rev1', 'expected', 'branch/sample.csv'); + }); + + test('upload source to the root of the project', async () => { + await switchConfig(ctx, 'project-root'); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sample.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + async function translationsFor(languageId: string, fileId: number): Promise<(string | null)[]> { + const response = await ctx.client.stringTranslationsApi.listLanguageTranslations(ctx.project.id, languageId, { + fileId, + }); + return response.data.map((entry) => ('text' in entry.data ? entry.data.text : null)); + } +}); diff --git a/tests/e2e/suites/project.test.ts b/tests/e2e/suites/project.test.ts new file mode 100644 index 000000000..bc3c55f33 --- /dev/null +++ b/tests/e2e/suites/project.test.ts @@ -0,0 +1,172 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { ProjectsGroupsModel } from '@crowdin/crowdin-api-client'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `project list` / `project add` (`cli/commands/project/ProjectCommand.ts`). + * + * `project browse` is not covered: `browseAction` calls `openUrl`, which spawns a real + * `open`/`xdg-open`, so a test would pop a browser tab on every run. Nothing in the command is + * injectable - covering it needs an opener seam in `cli/utils/open.ts`. + * + * Both subcommands are account-scoped. `project list` returns every project the token manages, so + * it is never snapshotted - assertions anchor on the suite's own project instead. `project add` + * creates projects `teardownSuite` knows nothing about, so each id is recorded and removed in + * `afterAll`. + */ + +describe('project', () => { + let ctx: SuiteContext; + const createdProjectIds: number[] = []; + + const projectName = (suffix: string) => `e2e-${Math.floor(Date.now() / 1000)}-project-${suffix}`; + + async function addProject(name: string, args: string[]): Promise<number> { + const result = await ctx.runner.run(['project', 'add', name, ...args]); + + expect(result).toMatchObject({ exitCode: 0 }); + + const id = Number(result.stdout.match(/#(\d+)/)?.[1]); + + expect(Number.isInteger(id)).toBe(true); + createdProjectIds.push(id); + + return id; + } + + async function listedProjects(args: string[] = []): Promise<Array<{ id: number; name: string }>> { + return runJson<Array<{ id: number; name: string }>>(ctx, ['project', 'list', ...args]); + } + + beforeAll(async () => { + ctx = await setupSuite('project'); + }); + + afterAll(async () => { + if (ctx && !ctx.env.keep) { + for (const id of createdProjectIds) { + try { + await ctx.client.projectsGroupsApi.deleteProject(id); + } catch (error) { + console.error(`Failed to delete project #${id}: ${error instanceof Error ? error.message : error}`); + } + } + } + + await teardownSuite(ctx); + }); + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['project']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage projects'); + expect(result.stdout).toContain('add <name>'); + expect(result.stdout).toContain('browse'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['project', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test("lists the projects the token manages, including this suite's own", async () => { + const projects = await listedProjects(); + + expect(projects).toContainEqual({ id: ctx.project.id, name: ctx.project.name }); + }); + + test('renders id and name in the default text format', async () => { + const result = await ctx.runner.run(['project', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`#${ctx.project.id} ${ctx.project.name}`); + }); + + test('adds type, visibility and last activity with --verbose', async () => { + const result = await ctx.runner.run(['project', 'list', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toMatch( + new RegExp(`#${ctx.project.id} ${ctx.project.name} file-based private \\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z`), + ); + }); + + test('renders the text line for --output plain, which has no plain branch of its own', async () => { + const result = await ctx.runner.run(['project', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + // `projectView` defines no `plain`, so `renderLine` falls back to `text` and the id keeps its + // `#`. + expect(result.stdout).toContain(`#${ctx.project.id} ${ctx.project.name}`); + }); + + test('serializes id and name only in a structured format', async () => { + const [project] = await listedProjects(); + + expect(Object.keys(project as object).sort()).toEqual(['id', 'name']); + }); + + test('requires a name to add', async () => { + const result = await ctx.runner.run(['project', 'add']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'name'"); + }); + + test('adds a project with target languages', async () => { + const name = projectName('basic'); + const id = await addProject(name, ['-l', 'uk', '-l', 'it']); + + const created = await ctx.client.projectsGroupsApi.getProject(id); + + expect(created.data.name).toBe(name); + expect(created.data.targetLanguageIds.sort()).toEqual(['it', 'uk']); + expect(created.data.sourceLanguageId).toBe('en'); + + expect(await listedProjects()).toContainEqual({ id, name }); + }); + + test('prints the bare id with --output plain', async () => { + const name = projectName('plain'); + const result = await ctx.runner.run(['project', 'add', name, '-l', 'uk', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + + // `projectAddView` does define a plain branch, unlike the listing: the bare id. + const id = Number(result.stdout.trim()); + + expect(Number.isInteger(id)).toBe(true); + createdProjectIds.push(id); + expect(result.stdout.trim()).toBe(String(id)); + }); + + test('adds a string-based project', async () => { + const id = await addProject(projectName('sb'), ['-l', 'uk', '--string-based']); + const created = await ctx.client.projectsGroupsApi.getProject(id); + + expect(created.data.type).toBe(ProjectsGroupsModel.Type.STRINGS_BASED); + }); + + test('adds a public project, private being the default', async () => { + const publicId = await addProject(projectName('public'), ['-l', 'uk', '--public']); + const privateId = await addProject(projectName('private'), ['-l', 'uk']); + + expect((await ctx.client.projectsGroupsApi.getProject(publicId)).data.visibility).toBe('open'); + expect((await ctx.client.projectsGroupsApi.getProject(privateId)).data.visibility).toBe('private'); + }); + + test('honours --source-language', async () => { + const id = await addProject(projectName('srclang'), ['-l', 'uk', '--source-language', 'de']); + + expect((await ctx.client.projectsGroupsApi.getProject(id)).data.sourceLanguageId).toBe('de'); + }); + + test('creates a project with no target languages when --language is omitted', async () => { + const id = await addProject(projectName('nolang'), []); + + expect((await ctx.client.projectsGroupsApi.getProject(id)).data.targetLanguageIds).toEqual([]); + }); +}); diff --git a/tests/e2e/suites/screenshot.test.ts b/tests/e2e/suites/screenshot.test.ts new file mode 100644 index 000000000..b4d832a45 --- /dev/null +++ b/tests/e2e/suites/screenshot.test.ts @@ -0,0 +1,267 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `screenshot list` / `screenshot upload` / `screenshot delete` + * (`cli/commands/screenshot/ScreenshotCommand.ts`). + * + * The image fixtures are real 32x32 PNGs because `upload` streams the file to storage and the API + * rejects anything undecodable. `images/not-an-image.txt` exists to reach the extension check; the + * directory check is reached by passing `images` itself. + * + * `--auto-tag` runs but tags nothing: flat-colour fixtures match no string, so `tagsCount` stays 0. + * The round trip is the subject, not the OCR result. + * + * `--label` is resolved two different ways: `upload` uses `resolveLabelIds` with the default + * `createMissing`, so the label is created and attached; `list` uses the command's own + * `resolveFilterLabelIds`, where an unknown title is an error because filtering must not create. + */ + +const LABEL = 'shot-label'; + +interface ListedScreenshot { + id: number; + tagsCount: number; + name: string; +} + +describe('screenshot', () => { + let ctx: SuiteContext; + let screenshotId: number; + + async function listScreenshots(args: string[] = []): Promise<ListedScreenshot[]> { + return runJson<ListedScreenshot[]>(ctx, ['screenshot', 'list', ...args]); + } + + beforeAll(async () => { + ctx = await setupSuite('screenshot', { targetLanguageIds: ['uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads the source file that --auto-tag targeting needs', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/1_android.xml'"); + }); + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['screenshot']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage screenshots'); + expect(result.stdout).toContain('upload <file>'); + expect(result.stdout).toContain('delete <id>'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['screenshot', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('reports a project with no screenshots', async () => { + const result = await ctx.runner.run(['screenshot', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('No screenshot found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('requires a file path', async () => { + const result = await ctx.runner.run(['screenshot', 'upload']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'file'"); + }); + + test('rejects a path that does not exist locally', async () => { + const result = await ctx.runner.run(['screenshot', 'upload', 'images/missing.png']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File 'images/missing.png' not found in the Crowdin project"); + }); + + test('rejects a directory', async () => { + const result = await ctx.runner.run(['screenshot', 'upload', 'images']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('The specified file is a directory'); + }); + + test('rejects a file that is not an allowed image format', async () => { + const result = await ctx.runner.run(['screenshot', 'upload', 'images/not-an-image.txt']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Wrong format of the file. Supported formats: jpeg, jpg, png, gif'); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test('requires --auto-tag alongside a targeting option', async () => { + const result = await ctx.runner.run([ + 'screenshot', + 'upload', + 'images/screenshot.png', + '--file', + 'sources/1_android.xml', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--auto-tag' is required for '--file' option"); + }); + + test('rejects more than one targeting option at a time', async () => { + const result = await ctx.runner.run([ + 'screenshot', + 'upload', + 'images/screenshot.png', + '--auto-tag', + '--file', + 'sources/1_android.xml', + '--branch', + 'some-branch', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "Only one of the following options can be used at a time: '--file', '--branch' or '--directory'", + ); + }); + + test('uploads a screenshot and attaches a label', async () => { + const result = await ctx.runner.run(['screenshot', 'upload', 'images/screenshot.png', '--label', LABEL]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('screenshot.png'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const [screenshot] = await listScreenshots(); + + expect(screenshot).toMatchObject({ name: 'screenshot.png', tagsCount: 0 }); + screenshotId = (screenshot as ListedScreenshot).id; + }); + + test('updates in place when the name already exists', async () => { + const result = await ctx.runner.run(['screenshot', 'upload', 'images/screenshot.png']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const screenshots = await listScreenshots(); + + // Same id, still one screenshot: the update branch ran, not the create branch. + expect(screenshots).toHaveLength(1); + expect(screenshots[0]?.id).toBe(screenshotId); + }); + + test('uploads a second screenshot with --auto-tag against a file', async () => { + const result = await ctx.runner.run([ + 'screenshot', + 'upload', + 'images/second.png', + '--auto-tag', + '--file', + 'sources/1_android.xml', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('second.png'); + expect(await listScreenshots()).toHaveLength(2); + }); + + test('lists both screenshots with id and tag count', async () => { + const result = await ctx.runner.run(['screenshot', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists a bare id and name with --output plain', async () => { + const result = await ctx.runner.run(['screenshot', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const lines = result.stdout.split('\n').filter((line) => line.length > 0); + + expect(lines.map((line) => line.replace(/^\d+ /, '')).sort()).toEqual(['screenshot.png', 'second.png']); + + for (const line of lines) { + expect(line).toMatch(/^\d+ \S/); + } + }); + + test('serializes id, tag count and name in a structured format', async () => { + const screenshots = (await listScreenshots()).sort((left, right) => (left.name < right.name ? -1 : 1)); + + expect(screenshots).toEqual([ + { id: expect.any(Number), tagsCount: 0, name: 'screenshot.png' }, + { id: expect.any(Number), tagsCount: 0, name: 'second.png' }, + ]); + }); + + test('filters by name with --search', async () => { + const screenshots = await listScreenshots(['--search', 'second']); + + expect(screenshots.map((screenshot) => screenshot.name)).toEqual(['second.png']); + }); + + test('filters by label, and by its absence', async () => { + // Only the first upload carried --label, so the filters partition the set. + expect((await listScreenshots(['--label', LABEL])).map((screenshot) => screenshot.name)).toEqual([ + 'screenshot.png', + ]); + expect((await listScreenshots(['--exclude-label', LABEL])).map((screenshot) => screenshot.name)).toEqual([ + 'second.png', + ]); + }); + + test('rejects a label the project does not have', async () => { + const result = await ctx.runner.run(['screenshot', 'list', '--label', 'no-such-label']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the 'no-such-label' label"); + }); + + test('rejects a non-numeric --string-id', async () => { + const result = await ctx.runner.run(['screenshot', 'list', '--string-id', 'abc']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("The '--string-id' value must be numeric"); + }); + + test('rejects a non-numeric id to delete', async () => { + const result = await ctx.runner.run(['screenshot', 'delete', 'abc']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Screenshot id must be numeric'); + }); + + test('warns instead of failing when the id is unknown', async () => { + const result = await ctx.runner.run(['screenshot', 'delete', '999999']); + + // deleteAction warns and returns rather than throwing, unlike `label delete`. + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("Couldn't find screenshot by the specified ID"); + }); + + test('deletes a screenshot by id', async () => { + const result = await ctx.runner.run(['screenshot', 'delete', String(screenshotId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('screenshot.png'); + expect(result.stdout).toContain('deleted successfully'); + + expect((await listScreenshots()).map((screenshot) => screenshot.name)).toEqual(['second.png']); + }); + + test('rejects an empty screenshot path', async () => { + const result = await ctx.runner.run(['screenshot', 'upload', '']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Screenshot file path can not be empty'); + }); +}); diff --git a/tests/e2e/suites/simple-csv.test.ts b/tests/e2e/suites/simple-csv.test.ts new file mode 100644 index 000000000..0f81f3f68 --- /dev/null +++ b/tests/e2e/suites/simple-csv.test.ts @@ -0,0 +1,182 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { copyFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +describe('simple csv', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('simple-csv', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain("Directory 'sources/files'"); + expect(result.stdout).toContain("File 'sources/files/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/2_simple.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates sources after local changes', async () => { + await copyFile( + join(ctx.workspace, 'sources_rev2', 'files', '1_simple.csv'), + join(ctx.workspace, 'sources', 'files', '1_simple.csv'), + ); + await copyFile( + join(ctx.workspace, 'sources_rev2', 'files', '2_simple.csv'), + join(ctx.workspace, 'sources', 'files', '2_simple.csv'), + ); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/files/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/2_simple.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for every target language', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'sources/files/it/1_simple.csv'"); + expect(result.stdout).toContain("Importing translations for file 'sources/files/it/2_simple.csv'"); + expect(result.stdout).toContain("Importing translations for file 'sources/files/uk/1_simple.csv'"); + expect(result.stdout).toContain("Importing translations for file 'sources/files/uk/2_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/it/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/it/2_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/uk/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/uk/2_simple.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for a single language', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-l', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'sources/files/uk/1_simple.csv'"); + expect(result.stdout).toContain("Importing translations for file 'sources/files/uk/2_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/uk/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/uk/2_simple.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for a single language', async () => { + const result = await ctx.runner.run(['download', 'translations', '-l', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // expected/uk/1_simple.csv leaves ident9 untranslated on purpose: that string carries + // max_length=10 while its uk translation ('файл 1 стрічка 9') is 16 characters, so Crowdin + // rejects it on import and the export returns the source text instead. Rows 1-8 (max_length=20) + // import fine. Note the CLI reports nothing about the rejected translation - the upload is + // reported as successful, which is worth a look on the product side. + await expectFilesMatch(ctx.workspace, 'sources/files', 'expected', 'uk/1_simple.csv', 'uk/2_simple.csv'); + }); + + test('downloads translations for every target language', async () => { + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'sources/files', + 'expected', + 'it/1_simple.csv', + 'it/2_simple.csv', + 'uk/1_simple.csv', + 'uk/2_simple.csv', + ); + }); + + test('uploads sources to a brand-new branch', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain("Directory 'sources/files'"); + expect(result.stdout).toContain("File 'sources/files/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/2_simple.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('updates sources on the branch (branch already exists)', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/files/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/2_simple.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations to the branch', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'sources/files/it/1_simple.csv'"); + expect(result.stdout).toContain("Importing translations for file 'sources/files/it/2_simple.csv'"); + expect(result.stdout).toContain("Importing translations for file 'sources/files/uk/1_simple.csv'"); + expect(result.stdout).toContain("Importing translations for file 'sources/files/uk/2_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/it/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/it/2_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/uk/1_simple.csv'"); + expect(result.stdout).toContain("File 'sources/files/uk/2_simple.csv'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for a single language on the branch', async () => { + const result = await ctx.runner.run(['download', 'translations', '-l', 'uk', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'sources/files', 'expected', 'uk/1_simple.csv', 'uk/2_simple.csv'); + }); + + test('downloads translations for every target language on the branch', async () => { + const result = await ctx.runner.run(['download', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch( + ctx.workspace, + 'sources/files', + 'expected', + 'it/1_simple.csv', + 'it/2_simple.csv', + 'uk/1_simple.csv', + 'uk/2_simple.csv', + ); + }); + + // The API rejects a CSV `scheme` missing the "Source String"/"Translation" elements at file creation. + // The branch and directory still get created (before the per-file create call), but every file fails + // and the command exits non-zero. The CLI's wrapper text is left to the snapshot. + test('rejects a scheme missing the Source String/Translation elements, on a new branch', async () => { + await switchConfig(ctx, 'invalid-scheme'); + + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch-invalid-scheme']); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain("Directory 'sources/files'"); + expect(result.stderr).toContain('The file schema must include the "Source String" and "Translation" elements'); + expect(result.stderr).toContain('Current execution finished with errors'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/status.test.ts b/tests/e2e/suites/status.test.ts new file mode 100644 index 000000000..258d73f6e --- /dev/null +++ b/tests/e2e/suites/status.test.ts @@ -0,0 +1,224 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { decode } from '@toon-format/toon'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `status` / `status translation` / `status proofreading` + * (`cli/commands/status/StatusCommand.ts`). + * + * `status` is one of only two `output.table()` callers, so this is the only suite exercising + * `normalize`'s table path - the rule that sorts `│ … │` rows inside their own table rather than + * merging every table into one block. + * + * The fixture makes every number deterministic and every filter observable: + * + * sources/1_android.xml 5 strings / 20 words + * sources/nested/2_android.xml 3 strings / 12 words + * translations/uk/** fully translated; no `it` translations + * + * leaving uk at 100% translated / 0% approved and it at 0% / 0%. Nothing approves a string, which + * is what makes the `--fail-if-incomplete` trio meaningful in both directions. + */ + +const BRANCH = 'status-branch'; + +interface ProgressEntry { + language: string; + translation: number; + approval: number; + totalWords?: number; + totalPhrases?: number; +} + +describe('status', () => { + let ctx: SuiteContext; + + async function statusJson(args: string[]): Promise<ProgressEntry[]> { + return runJson<ProgressEntry[]>(ctx, args); + } + + beforeAll(async () => { + ctx = await setupSuite('status', { targetLanguageIds: ['uk', 'it'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads the sources and the Ukrainian translations the rest of the suite reads', async () => { + const sources = await ctx.runner.run(['upload', 'sources']); + + expect(sources).toMatchObject({ exitCode: 0 }); + expect(sources.stdout).toContain("File 'sources/1_android.xml'"); + expect(sources.stdout).toContain("File 'sources/nested/2_android.xml'"); + + const translations = await ctx.runner.run(['upload', 'translations']); + + expect(translations).toMatchObject({ exitCode: 0 }); + expect(translations.stdout).toContain("File 'translations/uk/1_android.xml'"); + expect(translations.stdout).toContain("File 'translations/uk/nested/2_android.xml'"); + expect(translations.stderr).toContain("File 'translations/it/1_android.xml' does not exist"); + }); + + test('renders both languages as a table', async () => { + const result = await ctx.runner.run(['status']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds word and phrase columns with --verbose', async () => { + const result = await ctx.runner.run(['status', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Translated words'); + expect(result.stdout).toContain('Proofread phrases'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('shows only the translation column for `status translation`', async () => { + const result = await ctx.runner.run(['status', 'translation']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Translated'); + expect(result.stdout).not.toContain('Proofread'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('shows only the proofreading column for `status proofreading`', async () => { + const result = await ctx.runner.run(['status', 'proofreading']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Proofread'); + expect(result.stdout).not.toContain('Translated'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('serializes one entry per language in a structured format', async () => { + expect(await statusJson(['status'])).toEqual([ + { language: 'it', translation: 0, approval: 0 }, + { language: 'uk', translation: 100, approval: 0 }, + ]); + }); + + test('serializes the same entries in the toon output', async () => { + // `status` is the only command that reaches output.table() in a structured format. + const result = await ctx.runner.run(['status', '--output', 'toon']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(decode(result.stdout)).toEqual([ + { language: 'it', translation: 0, approval: 0 }, + { language: 'uk', translation: 100, approval: 0 }, + ]); + }); + + test('prints a titled section per metric with --output plain', async () => { + const result = await ctx.runner.run(['status', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout.trimEnd()).toBe(['Translated:', 'it 0', 'uk 100', 'Proofread:', 'it 0', 'uk 0'].join('\n')); + }); + + test('adds the count sections to --output plain with --verbose', async () => { + const result = await ctx.runner.run(['status', '--output', 'plain', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Translated words:\nit 0/32\nuk 32/32'); + expect(result.stdout).toContain('Translated phrases:\nit 0/8\nuk 8/8'); + expect(result.stdout).toContain('Proofread words:\nit 0/32\nuk 0/32'); + }); + + test('filters to a single language with --language', async () => { + expect(await statusJson(['status', '-l', 'uk'])).toEqual([{ language: 'uk', translation: 100, approval: 0 }]); + }); + + test('rejects a language the project does not target', async () => { + const result = await ctx.runner.run(['status', '-l', 'zz']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Language 'zz' doesn't exist in the project. Try specifying another language code"); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test('scopes the progress to one file with --file', async () => { + // Counts, not percentages: uk is 100% at every scope, so only the totals prove the filter ran. + const [italian] = await statusJson(['status', '-f', 'sources/1_android.xml', '--verbose']); + + expect(italian).toMatchObject({ language: 'it', totalPhrases: 5, totalWords: 20 }); + }); + + test('rejects a file the project does not contain', async () => { + const result = await ctx.runner.run(['status', '-f', 'nope.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the 'nope.xml' file"); + }); + + test('scopes the progress to one directory with --directory', async () => { + const [italian] = await statusJson(['status', '-d', 'sources/nested', '--verbose']); + + expect(italian).toMatchObject({ language: 'it', totalPhrases: 3, totalWords: 12 }); + }); + + test('rejects a directory the project does not contain', async () => { + const result = await ctx.runner.run(['status', '-d', 'nope']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the 'nope' directory"); + }); + + test('rejects --file and --directory together', async () => { + const result = await ctx.runner.run(['status', '-f', 'sources/1_android.xml', '-d', 'sources/nested']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Only one of the following options can be used at a time: '--file', '--directory'"); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test('fails on an incomplete project with --fail-if-incomplete', async () => { + const result = await ctx.runner.run(['status', '--fail-if-incomplete']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('The current project is incomplete'); + // The check runs after the table is printed, so a failing run still shows what is behind. + expect(result.stdout).toContain('Translated'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('passes --fail-if-incomplete for a fully translated language', async () => { + // The one genuinely complete combination here: `status translation` ignores approvals. + const result = await ctx.runner.run(['status', 'translation', '-l', 'uk', '--fail-if-incomplete']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).not.toContain('incomplete'); + }); + + test('fails --fail-if-incomplete for proofreading, which nothing here approves', async () => { + const result = await ctx.runner.run(['status', 'proofreading', '--fail-if-incomplete']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('The current project is incomplete'); + }); + + // Must stay last: `upload sources -b` adds a second untranslated copy of every string, dropping + // project-wide progress below 100% for every test above. + test('scopes the progress to a branch with --branch', async () => { + const upload = await ctx.runner.run(['upload', 'sources', '-b', BRANCH]); + + expect(upload).toMatchObject({ exitCode: 0 }); + + // The branch has its own untranslated copies, so 0% here against uk 100% on the root tree. + expect(await statusJson(['status', '-b', BRANCH])).toEqual([ + { language: 'it', translation: 0, approval: 0 }, + { language: 'uk', translation: 0, approval: 0 }, + ]); + }); + + test('rejects a branch that does not exist', async () => { + const result = await ctx.runner.run(['status', '-b', 'nope']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The branch with the specified name doesn't exist in the project"); + }); +}); diff --git a/tests/e2e/suites/string.test.ts b/tests/e2e/suites/string.test.ts new file mode 100644 index 000000000..e9bad309b --- /dev/null +++ b/tests/e2e/suites/string.test.ts @@ -0,0 +1,670 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { decode } from '@toon-format/toon'; +import { findBranch, findCommentId, findFileId, findStringId } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { + createExtraProject, + runJson, + type SuiteContext, + setupSuite, + switchConfig, + teardownSuite, +} from '../helpers/suite.ts'; + +/** + * Covers `StringCommand.ts` + * (list/add/edit/delete) and `CommentCommand.ts` (add/list/resolve). + * + * The "file does not support online string managing/editing" wording is API-owned and has differed + * between sources, so only the common substring "does not support online string" is asserted. + */ +describe('string', () => { + let ctx: SuiteContext; + let thirdStringId: number; + let fourthStringId: number; + let branchId: number; + let branchStr2Id: number; + let contextRequestCommentId: number; + let stringsBasedProjectId: number; + + beforeAll(async () => { + ctx = await setupSuite('string'); + // The string-based guards need a project of the other kind to fire against; this suite's own is + // file-based. + stringsBasedProjectId = await createExtraProject(ctx, { suite: 'string-strings-based', stringsBased: true }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + async function labelTitles(labelIds: number[]): Promise<string[]> { + if (labelIds.length === 0) { + return []; + } + + const response = await ctx.client.labelsApi.withFetchAll().listLabels(ctx.project.id); + const titleById = new Map(response.data.map((entry) => [entry.data.id, entry.data.title])); + + return labelIds.map((id) => titleById.get(id) ?? String(id)); + } + + test('uploads sources', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android.xml'"); + expect(result.stdout).toContain("File 'text.txt'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists all source strings', async () => { + const result = await ctx.runner.run(['string', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('first string'); + expect(result.stdout).toContain('second string'); + expect(result.stdout).toContain('first string source` with tag'); + expect(result.stdout).toContain("first string source' with quotes"); + expect(result.stdout).toContain('First text string.'); + expect(result.stdout).toContain('Second text string.'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists source strings authenticating via -T/-i against a config without an api_token', async () => { + await switchConfig(ctx, 'without-token'); + + const result = await ctx.runner.run([ + 'string', + 'list', + '-T', + ctx.env.token as string, + '-i', + String(ctx.project.id), + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('first string'); + expect(result.stdout).toContain('second string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists source strings filtered by file', async () => { + // Restores the full config (token + project id + both file entries). + await switchConfig(ctx, 'default'); + + const result = await ctx.runner.run(['string', 'list', '--file', 'android.xml']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('first string'); + expect(result.stdout).toContain('second string'); + expect(result.stdout).toContain('first string source` with tag'); + expect(result.stdout).not.toContain('First text string.'); + expect(result.stdout).not.toContain('Second text string.'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists source strings filtered by identifier/text/context', async () => { + const result = await ctx.runner.run(['string', 'list', '--filter', 'str1']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('first string'); + expect(result.stdout).not.toContain('second string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists source strings verbosely, including file and context', async () => { + const result = await ctx.runner.run(['string', 'list', '-v']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('android.xml'); + expect(result.stdout).toContain('text.txt'); + expect(result.stdout).toContain('str1'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds a new source string', async () => { + const result = await ctx.runner.run([ + 'string', + 'add', + 'third string', + '--identifier', + 'str3', + '--file', + 'android.xml', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('str3'); + expect(result.stdout).toContain('third string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + thirdStringId = await findStringId(ctx, 'third string'); + const added = await ctx.client.sourceStringsApi.getString(ctx.project.id, thirdStringId); + // Stored verbatim, not md5-hashed, even for an Android-XML identifier. + expect(added.data.identifier).toBe('str3'); + expect(added.data.maxLength).toBe(0); + expect(added.data.context).toBe('str3'); + expect(added.data.isHidden).toBe(false); + }); + + test('adds a new source string with all parameters', async () => { + const result = await ctx.runner.run([ + 'string', + 'add', + 'fourth string', + '--identifier', + 'str4', + '--max-length', + '10', + '--context', + 'simple context', + '--file', + 'android.xml', + '--label', + 'android_file', + '--hidden', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('str4'); + expect(result.stdout).toContain('fourth string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + fourthStringId = await findStringId(ctx, 'fourth string'); + const added = await ctx.client.sourceStringsApi.getString(ctx.project.id, fourthStringId); + expect(added.data.identifier).toBe('str4'); + expect(added.data.maxLength).toBe(10); + expect(added.data.context).toBe('str4\nsimple context'); + expect(added.data.isHidden).toBe(true); + expect(await labelTitles(added.data.labelIds)).toEqual(['android_file']); + }); + + test('edits a source string', async () => { + const result = await ctx.runner.run([ + 'string', + 'edit', + String(thirdStringId), + '--text', + 'third string edited', + '--label', + 'android_file', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('was updated successfully'); + expect(result.stdout).toContain('third string edited'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const edited = await ctx.client.sourceStringsApi.getString(ctx.project.id, thirdStringId); + expect(edited.data.identifier).toBe('str3'); + expect(edited.data.maxLength).toBe(0); + expect(edited.data.context).toBe('str3'); + expect(edited.data.isHidden).toBe(false); + expect(await labelTitles(edited.data.labelIds)).toEqual(['android_file']); + }); + + test('edits a source string with all parameters', async () => { + const result = await ctx.runner.run([ + 'string', + 'edit', + String(fourthStringId), + '--text', + 'fourth string edited', + '--max-length', + '0', + '--context', + 'simple context edited', + '--no-hidden', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('was updated successfully'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const edited = await ctx.client.sourceStringsApi.getString(ctx.project.id, fourthStringId); + expect(edited.data.identifier).toBe('str4'); + expect(edited.data.maxLength).toBe(0); + expect(edited.data.context).toBe('simple context edited'); + expect(edited.data.isHidden).toBe(false); + }); + + test('deletes a source string', async () => { + const result = await ctx.runner.run(['string', 'delete', String(thirdStringId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('was deleted successfully'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources to a new branch', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android.xml'"); + expect(result.stdout).toContain("File 'text.txt'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + branchId = (await findBranch(ctx, 'test-branch')).id; + }); + + test('adds a source string to a branch-scoped file', async () => { + const result = await ctx.runner.run([ + 'string', + 'add', + 'third string', + '--identifier', + 'str3', + '--branch', + 'test-branch', + '--file', + '/android.xml', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('str3'); + expect(result.stdout).toContain('third string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('deletes a source string from a branch-scoped file', async () => { + const id = await findStringId(ctx, 'first string', { branchId }); + const result = await ctx.runner.run(['string', 'delete', String(id)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('was deleted successfully'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports a missing file when listing by file', async () => { + const result = await ctx.runner.run(['string', 'list', '--file', 'not-exists-file.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File 'not-exists-file.xml' not found"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('warns then fails adding a string to a missing file', async () => { + const result = await ctx.runner.run(['string', 'add', 'simple string', '--file', 'not-exists-file.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the 'not-exists-file.xml' file"); + expect(result.stderr).toContain('No valid file specified for the string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('fails adding a string to an unsupported file type', async () => { + const result = await ctx.runner.run(['string', 'add', 'simple string', '--file', 'text.txt']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('does not support online string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('requires non-empty text when adding a string', async () => { + const result = await ctx.runner.run(['string', 'add', '', '--file', 'android.xml']); + + // Rejected by the CLI before any API call, so the API's own `isEmpty` errors never occur. + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Source string text can not be empty'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('requires an identifier when adding a string without one', async () => { + const result = await ctx.runner.run(['string', 'add', 'simple string', '--file', 'android.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Value is required and can't be empty"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports a missing string when editing a nonexistent id', async () => { + const result = await ctx.runner.run(['string', 'edit', '999999', '--text', 'simple string']); + + expect(result.exitCode).toBe(102); + expect(result.stderr).toContain('String Not Found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('fails editing a string in an unsupported file type', async () => { + const textFileId = await findFileId(ctx, '/text.txt'); + const id = await findStringId(ctx, 'First text string.', { fileId: textFileId }); + const result = await ctx.runner.run(['string', 'edit', String(id), '--text', 'simple string']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('does not support online string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('reports a missing string when deleting a nonexistent id', async () => { + const result = await ctx.runner.run(['string', 'delete', '999999']); + + expect(result.exitCode).toBe(102); + expect(result.stderr).toContain('String Not Found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test.each(['json', 'toon'] as const)('reports that failure as a %s record carrying the exit code', async (format) => { + const result = await ctx.runner.run(['string', 'delete', '999999', '--output', format]); + const record = (format === 'json' ? JSON.parse : decode)(result.stderr) as { + level: string; + message: string; + code: number; + }; + + expect(result.exitCode).toBe(102); + expect(record.level).toBe('error'); + expect(record.message).toContain('String Not Found'); + expect(record.code).toBe(102); + expect(result.stdout.trim()).toBe(''); + }); + + test('fails deleting a string in an unsupported file type', async () => { + const textFileId = await findFileId(ctx, '/text.txt'); + const id = await findStringId(ctx, 'First text string.', { fileId: textFileId }); + const result = await ctx.runner.run(['string', 'delete', String(id)]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('does not support online string'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists source strings by a CroQL expression', async () => { + const result = await ctx.runner.run(['string', 'list', '--croql', 'type is plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('first string'); + expect(result.stdout).toContain('fourth string edited'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists source strings by a CroQL text match, spanning both branches', async () => { + const result = await ctx.runner.run([ + 'string', + 'list', + '--croql', + 'text = "<span>first string source` with tag</span>"', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + // One copy from the initial upload, one from the branch upload. + const matches = result.stdout.split('first string source` with tag</span>').length - 1; + expect(matches).toBe(2); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists source strings by a CroQL text match with a quote, spanning both branches', async () => { + const result = await ctx.runner.run(['string', 'list', '--croql', `text = "first string source' with quotes"`]); + + expect(result).toMatchObject({ exitCode: 0 }); + const matches = result.stdout.split("first string source' with quotes").length - 1; + expect(matches).toBe(2); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('rejects an invalid CroQL expression', async () => { + const result = await ctx.runner.run(['string', 'list', '--croql', '11111111111']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The inferred type is not equal to 'bool'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds a comment to a source string', async () => { + const id = await findStringId(ctx, "first string source' with quotes", { branchId }); + const result = await ctx.runner.run(['comment', 'add', 'Added comment', '--string-id', String(id), '-l', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Added comment'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists comments', async () => { + const result = await ctx.runner.run(['comment', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Added comment'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds an issue to a source string', async () => { + branchStr2Id = await findStringId(ctx, 'second string', { branchId }); + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Added comment string id 10', + '--string-id', + String(branchStr2Id), + '-l', + 'uk', + '--type', + 'issue', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Added comment string id 10'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds an issue with a context-request type', async () => { + const result = await ctx.runner.run([ + 'comment', + 'add', + 'Added issue string context_request id 10', + '--string-id', + String(branchStr2Id), + '-l', + 'uk', + '--type', + 'issue', + '--issue-type', + 'context_request', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Added issue string context_request id 10'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + contextRequestCommentId = await findCommentId(ctx, 'Added issue string context_request id 10'); + }); + + test('lists comments filtered by a specific string id', async () => { + const result = await ctx.runner.run(['comment', 'list', '--string-id', String(branchStr2Id)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Added comment string id 10'); + expect(result.stdout).toContain('Added issue string context_request id 10'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('resolves a string issue', async () => { + const result = await ctx.runner.run(['comment', 'resolve', String(contextRequestCommentId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('has been successfully resolved'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists comments filtered by unresolved status', async () => { + const result = await ctx.runner.run(['comment', 'list', '--status', 'unresolved']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Added comment string id 10'); + // The context-request issue was just resolved above, so it must not show up here anymore. + expect(result.stdout).not.toContain('Added issue string context_request id 10'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + // Everything below runs after the listing snapshots above, so the strings these tests add cannot + // shift them. The option guards throw before any request, so they cost nothing. + + test('rejects --file and --directory together', async () => { + const result = await ctx.runner.run(['string', 'list', '--file', 'android.xml', '--directory', 'sources']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--file' and '--directory' options can't be used together"); + }); + + test('rejects --scope without --filter', async () => { + const result = await ctx.runner.run(['string', 'list', '--scope', 'identifier']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--scope' option can only be used together with '--filter'"); + }); + + test('rejects --croql alongside another filter', async () => { + const result = await ctx.runner.run(['string', 'list', '--croql', 'text = "x"', '--filter', 'str']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--croql' option can't be used together with --filter"); + }); + + test('names every filter that conflicts with --croql, not just the first', async () => { + const result = await ctx.runner.run([ + 'string', + 'list', + '--croql', + 'text = "x"', + '--filter', + 'str', + '--file', + 'android.xml', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--croql' option can't be used together with --filter, --file"); + }); + + test('rejects a negative --max-length when adding', async () => { + const result = await ctx.runner.run(['string', 'add', 'negative', '--file', 'android.xml', '--max-length=-1']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--max-length' cannot be lower than 0"); + }); + + test('rejects a negative --max-length when editing', async () => { + const result = await ctx.runner.run(['string', 'edit', String(thirdStringId), '--max-length=-1']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--max-length' cannot be lower than 0"); + }); + + test('requires at least one parameter on edit', async () => { + const result = await ctx.runner.run(['string', 'edit', String(thirdStringId)]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Specify some parameters to edit the string'); + }); + + test('requires --file when adding to a file-based project', async () => { + const result = await ctx.runner.run(['string', 'add', 'no file given']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--file' value can not be empty"); + }); + + test('reports a directory the project does not contain', async () => { + const result = await ctx.runner.run(['string', 'list', '--directory', 'no-such-directory']); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('no-such-directory'); + }); + + test('stores a plural text built from the plural-form options', async () => { + // The id comes from the command's own echo: findStringId matches on `data.text`, which for a + // plural string is an object rather than the string it was created from. A file-based add + // prints a list (one entry per resolved `--file`), not a single item. + const [echoed] = await runJson<{ id: number }[]>(ctx, [ + 'string', + 'add', + 'other form', + '--identifier', + 'plural_str', + '--file', + 'android.xml', + '--one', + 'one form', + ]); + const added = await ctx.client.sourceStringsApi.getString(ctx.project.id, echoed?.id as number); + + // `other` comes from the positional argument, `one` from the flag. Only the forms the source + // language actually has may be sent - English has [one, other], and the API rejects the rest + // with "Unknown [few] in this locale", so this is not the place to pass all five. + expect(added.data.text).toEqual({ other: 'other form', one: 'one form' }); + }); + + test('narrows the json listing to the view keys, and widens it with --verbose', async () => { + const plain = await runJson<object[]>(ctx, ['string', 'list']); + const verbose = await runJson<object[]>(ctx, ['string', 'list', '-v']); + + const plainKeys = plain.map((entry) => Object.keys(entry).join()); + const verboseKeys = verbose.map((entry) => Object.keys(entry).join()); + + expect(new Set(plainKeys)).toEqual(new Set(['id,identifier,text'])); + expect(new Set(verboseKeys)).toEqual(new Set(['id,identifier,text,fileId,labelIds,context'])); + }); + + test('carries the same listing in the toon output', async () => { + const toon = await ctx.runner.run(['string', 'list', '--output', 'toon']); + + expect(toon).toMatchObject({ exitCode: 0 }); + expect(await runJson(ctx, ['string', 'list'])).toEqual(decode(toon.stdout)); + }); + + test('lists bare string ids with --output plain', async () => { + const result = await ctx.runner.run(['string', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const lines = result.stdout.split('\n').filter((line) => line.length > 0); + + const ids = (await runJson<{ id: number }[]>(ctx, ['string', 'list'])).map((entry) => String(entry.id)); + + expect(lines.every((line) => /^\d+$/.test(line))).toBe(true); + expect(lines.sort()).toEqual(ids.sort()); + }); + + test('rejects --file when listing a string-based project', async () => { + const result = await ctx.runner.run([ + 'string', + 'list', + '--file', + 'android.xml', + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "The '--file' and '--directory' options are not supported for string-based projects", + ); + }); + + test('rejects --file when adding to a string-based project', async () => { + const result = await ctx.runner.run([ + 'string', + 'add', + 'strings based', + '--file', + 'android.xml', + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--file' option is not supported for string-based projects"); + }); + + test('requires --branch when adding to a string-based project', async () => { + const result = await ctx.runner.run([ + 'string', + 'add', + 'strings based', + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--branch' option is required for string-based projects"); + }); +}); diff --git a/tests/e2e/suites/task.test.ts b/tests/e2e/suites/task.test.ts new file mode 100644 index 000000000..030a6c9f6 --- /dev/null +++ b/tests/e2e/suites/task.test.ts @@ -0,0 +1,334 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers `task list` / `task add` (`cli/commands/task/TaskCommand.ts`). + * + * Runs against crowdin.com, so `addAction` takes its non-Enterprise branch: `--type` is required + * and `--workflow-step` never applies. The Enterprise branch is unreachable here - the harness has + * no notion of an organization, the same limit `invalid-credentials.test.ts` records. + * + * Two server rules dictate the fixture and the test order: a `translate` task needs UNtranslated + * words, a `proofread` task needs translated-but-unapproved ones. So the fixture ships Italian + * translations and no Ukrainian ones, and each task targets a distinct file/language pair - a + * second task over the same strings is rejected with "Language has no untranslated words". + * + * `--label` filters a task to strings carrying the label, so the sources are uploaded with one + * attached. This is `resolveLabelIds(titles, false)`: an unknown title is an error rather than a + * new empty label, the counterpart to the createMissing path `label.test.ts` covers. + * + * `task`'s `--file` has NO short flag, unlike `status`'s `-f`. + */ + +const LABEL = 'task-label'; + +interface ListedTask { + id: number; + targetLanguageId: string; + title: string; +} + +describe('task', () => { + let ctx: SuiteContext; + + async function listTitles(args: string[] = []): Promise<string[]> { + return (await runJson<ListedTask[]>(ctx, ['task', 'list', ...args])).map((task) => task.title).sort(); + } + + beforeAll(async () => { + ctx = await setupSuite('task', { targetLanguageIds: ['uk', 'it'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads the labelled sources and Italian translations the rest of the suite needs', async () => { + // Creates the label and attaches it to every string, so a label-filtered task has material. + const sources = await ctx.runner.run(['upload', 'sources', '--label', LABEL]); + + expect(sources).toMatchObject({ exitCode: 0 }); + expect(sources.stdout).toContain("File 'sources/1_android.xml'"); + expect(sources.stdout).toContain("File 'sources/2_android.xml'"); + + const translations = await ctx.runner.run(['upload', 'translations']); + + expect(translations).toMatchObject({ exitCode: 0 }); + expect(translations.stdout).toContain("File 'translations/it/1_android.xml'"); + expect(translations.stderr).toContain("File 'translations/uk/1_android.xml' does not exist"); + }); + + test('prints help when invoked without a subcommand', async () => { + const result = await ctx.runner.run(['task']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Manage tasks'); + expect(result.stdout).toContain('add <title>'); + }); + + test('rejects an unknown subcommand', async () => { + const result = await ctx.runner.run(['task', 'bogus']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("unknown command 'bogus'"); + }); + + test('reports a project with no tasks', async () => { + const result = await ctx.runner.run(['task', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('No tasks found'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('requires a title', async () => { + const result = await ctx.runner.run(['task', 'add']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("missing required argument 'title'"); + }); + + test('requires a language', async () => { + const result = await ctx.runner.run(['task', 'add', 'T1']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Language can not be empty. (e.g. es-ES, en-US)'); + }); + + test('requires at least one file', async () => { + const result = await ctx.runner.run(['task', 'add', 'T1', '--language', 'uk']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The '--file' value can not be empty"); + }); + + test('requires a type outside Enterprise', async () => { + const result = await ctx.runner.run(['task', 'add', 'T1', '--language', 'uk', '--file', 'sources/1_android.xml']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Task type can not be empty. Possible values: translate, proofread'); + }); + + test('rejects an unsupported type', async () => { + const result = await ctx.runner.run([ + 'task', + 'add', + 'T1', + '--language', + 'uk', + '--file', + 'sources/1_android.xml', + '--type', + 'bogus', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Unsupported task type. Possible values: translate, proofread'); + }); + + test("rejects --include-pre-translated-strings-only on a 'translate' task", async () => { + const result = await ctx.runner.run([ + 'task', + 'add', + 'T1', + '--language', + 'uk', + '--file', + 'sources/1_android.xml', + '--type', + 'translate', + '--include-pre-translated-strings-only', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "The '--include-pre-translated-strings-only' option can't be used with the 'translate' task type", + ); + expect(normalize(result.stderr)).toMatchSnapshot(); + }); + + test('warns per unknown file and then refuses to create the task', async () => { + const result = await ctx.runner.run([ + 'task', + 'add', + 'T1', + '--language', + 'uk', + '--file', + 'nope.xml', + '--type', + 'translate', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the 'nope.xml' file"); + expect(result.stderr).toContain('No valid file specified for the task. At least one valid file is required'); + }); + + test('rejects a label the project does not have', async () => { + // A filtering caller never creates the label: a fresh empty one would cover nothing. + const result = await ctx.runner.run([ + 'task', + 'add', + 'T1', + '--language', + 'uk', + '--file', + 'sources/1_android.xml', + '--type', + 'translate', + '--label', + 'no-such-label', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Project doesn't contain the 'no-such-label' label"); + }); + + test('adds a translate task', async () => { + const result = await ctx.runner.run([ + 'task', + 'add', + 'Translate file one', + '--language', + 'uk', + '--file', + 'sources/1_android.xml', + '--type', + 'translate', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('uk Translate file one'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds a translate task filtered by label', async () => { + // A different file: the task above already covers every untranslated `uk` word in file one. + const result = await ctx.runner.run([ + 'task', + 'add', + 'Labelled file two', + '--language', + 'uk', + '--file', + 'sources/2_android.xml', + '--type', + 'translate', + '--label', + LABEL, + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('uk Labelled file two'); + }); + + test('adds a proofread task with a description', async () => { + // `it` is the only language with translations, so the only one with words to proofread. + const result = await ctx.runner.run([ + 'task', + 'add', + 'Proofread file two', + '--language', + 'it', + '--file', + 'sources/2_android.xml', + '--type', + 'proofread', + '--description', + 'Please proofread the second file', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('it Proofread file two'); + }); + + test('lists every task with its id and target language', async () => { + const result = await ctx.runner.run(['task', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('adds status, word count and due date with --verbose', async () => { + const result = await ctx.runner.run(['task', 'list', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + // Counts come from the fixture: 3 x 4 words for file one, 2 x 4 for file two. + expect(result.stdout).toContain('todo 12 NoDueDate'); + expect(result.stdout).toContain('todo 8 NoDueDate'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('lists a bare id and title with --output plain', async () => { + const result = await ctx.runner.run(['task', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const lines = result.stdout.split('\n').filter((line) => line.length > 0); + + // The plain view prints a bare id, which `normalize` does not mask, so strip it and sort by + // title - a leading id would otherwise decide the order. + expect(lines.map((line) => line.replace(/^\d+ /, '')).sort()).toEqual([ + 'Labelled file two', + 'Proofread file two', + 'Translate file one', + ]); + + for (const line of lines) { + expect(line).toMatch(/^\d+ \S/); + } + }); + + test('serializes id, target language and title in a structured format', async () => { + const tasks = (await runJson<ListedTask[]>(ctx, ['task', 'list'])).sort((left, right) => + left.title < right.title ? -1 : 1, + ); + + expect(tasks).toEqual([ + { id: expect.any(Number), targetLanguageId: 'uk', title: 'Labelled file two' }, + { id: expect.any(Number), targetLanguageId: 'it', title: 'Proofread file two' }, + { id: expect.any(Number), targetLanguageId: 'uk', title: 'Translate file one' }, + ]); + }); + + test('filters by status', async () => { + expect(await listTitles(['--status', 'todo'])).toEqual([ + 'Labelled file two', + 'Proofread file two', + 'Translate file one', + ]); + expect(await listTitles(['--status', 'done'])).toEqual([]); + }); + + test('rejects an unsupported status', async () => { + const result = await ctx.runner.run(['task', 'list', '--status', 'bogus']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Unsupported status: 'bogus'"); + }); + + test('rejects a non-numeric --assignee-id', async () => { + const result = await ctx.runner.run(['task', 'list', '--assignee-id', 'abc']); + + // toNumberArray raises a validation error, so exit 2 rather than the generic 1. + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("The '--assignee-id' value must be numeric"); + }); + + test('filters by assignee, client-side', async () => { + // Nothing assigns anyone, so any id empties the list - listAction filters client-side. + const result = await ctx.runner.run(['task', 'list', '--assignee-id', '999999']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('No tasks found'); + }); + + test('rejects an empty task title', async () => { + const result = await ctx.runner.run(['task', 'add', '']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Task title can not be empty'); + }); +}); diff --git a/tests/e2e/suites/tm.test.ts b/tests/e2e/suites/tm.test.ts new file mode 100644 index 000000000..d1ce62437 --- /dev/null +++ b/tests/e2e/suites/tm.test.ts @@ -0,0 +1,510 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { decode } from '@toon-format/toon'; +import AdmZip from 'adm-zip'; +import { findTmId } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { runJson, type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** Crowdin.com auto-creates a TM named after every project. */ +function defaultTmName(ctx: SuiteContext): string { + return `${ctx.project.name}'s TM`; +} + +/** + * Order-independent TMX content check, grouped by language: the server re-exports TMX in its own + * dialect (different `tuid`s, added `creationid`/`creationdate`, `<tu>`/`<tuv>` reordered - the `en` + * `<tuv>` comes first in the server's own export, `ar` first in the uploaded source), so + * byte-equality isn't meaningful. Compare the sorted set of `<seg>` texts per `xml:lang`, which the + * roundtrip/filtering must preserve. + */ +function extractTmxSegmentsByLanguage(xml: string): Record<string, string[]> { + const byLanguage: Record<string, string[]> = {}; + + // The exporter writes `<tuv xml:lang="en" creationid="..." creationdate="...">`, so the attribute + // list around xml:lang has to be tolerated - anchoring on `">` alone matches nothing. + for (const tuvMatch of xml.matchAll(/<tuv\b[^>]*\bxml:lang="([^"]+)"[^>]*>([\s\S]*?)<\/tuv>/g)) { + const language = tuvMatch[1] as string; + const segMatch = /<seg>([\s\S]*?)<\/seg>/.exec(tuvMatch[2] as string); + + if (segMatch) { + if (!byLanguage[language]) { + byLanguage[language] = []; + } + + byLanguage[language].push(segMatch[1] as string); + } + } + + for (const language of Object.keys(byLanguage)) { + (byLanguage[language] as string[]).sort(); + } + + return byLanguage; +} + +async function sortedLines(path: string): Promise<string[]> { + const content = await Bun.file(path).text(); + return content.split('\n').sort(); +} + +/** + * Order-independent XLSX content check. An xlsx is a zip container, so raw byte-equality isn't + * reliable (zip/docProps metadata differs run to run) - unzip with `adm-zip` and compare the sorted + * set of visible text runs from both the shared-strings table and the worksheet's own inline + * strings, covering either encoding a workbook writer may choose. + */ +function extractXlsxTexts(path: string): string[] { + const zip = new AdmZip(path); + const texts: string[] = []; + + for (const entryName of ['xl/sharedStrings.xml', 'xl/worksheets/sheet1.xml']) { + const entry = zip.getEntry(entryName); + + if (entry) { + const xml = entry.getData().toString('utf-8'); + texts.push(...[...xml.matchAll(/<t(?=[\s>])[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1] as string)); + } + } + + return texts.sort(); +} + +/** + * The three names the CLI derives from this suite's fixtures (`Created in Crowdin CLI (<file>)`). + * + * Translation memories belong to the account, not to the project, so `teardownSuite` cannot reach + * them - and since the name comes from the uploaded file, a leftover from an interrupted run makes + * every `tm upload` below fail with "The name '...' is already taken". They are swept both before + * the suite (self-healing) and after it. + */ +const SUITE_TM_NAMES = ['simple-tm.tmx', 'simple-tm.csv', 'simple-tm.xlsx'].map( + (file) => `Created in Crowdin CLI (${file})`, +); + +/** Far outside the account's id range, so `tmService.get` answers 404 rather than someone's TM. */ +const MISSING_TM_ID = 999999999; + +/** + * This suite's own rows out of an account-wide listing, sorted by name. Takes either a decoded value + * or the raw json, so the two structured formats can be compared to each other. + */ +function suiteEntries(listing: string | unknown): { id: number; name: string; segmentsCount: number }[] { + const rows = (typeof listing === 'string' ? JSON.parse(listing) : listing) as { + id: number; + name: string; + segmentsCount: number; + }[]; + + return rows.filter((row) => SUITE_TM_NAMES.includes(row.name)).sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Deletes every account TM this suite owns by name. Never throws: cleanup must not mask a result. */ +async function removeSuiteTms(ctx: SuiteContext): Promise<void> { + try { + const response = await ctx.client.translationMemoryApi.withFetchAll().listTm(); + + for (const entry of response.data) { + if (SUITE_TM_NAMES.includes(entry.data.name)) { + await ctx.client.translationMemoryApi.deleteTm(entry.data.id); + } + } + } catch (error) { + console.warn(`Failed to clean up this suite's translation memories: ${error}`); + } +} + +describe('tm', () => { + let ctx: SuiteContext; + let tmxId: number; + let csvId: number; + let xlsxId: number; + + beforeAll(async () => { + ctx = await setupSuite('tm'); + await removeSuiteTms(ctx); + }); + + afterAll(async () => { + if (ctx && !ctx.env.keep) { + await removeSuiteTms(ctx); + } + + await teardownSuite(ctx); + }); + + // `uploadAction` validates before it builds any service, so none of these reach the API. + test('rejects a file that does not exist', async () => { + const result = await ctx.runner.run(['tm', 'upload', 'sources/missing.tmx', '--language', 'en']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File 'sources/missing.tmx' not found in the Crowdin project"); + }); + + test('rejects a directory', async () => { + const result = await ctx.runner.run(['tm', 'upload', 'sources', '--language', 'en']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('The specified file is a directory'); + }); + + test('rejects a CSV without a scheme', async () => { + const result = await ctx.runner.run(['tm', 'upload', 'sources/simple-tm.csv', '--language', 'uk']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Scheme is required for CSV or XLS/XLSX files'); + }); + + test('rejects a malformed --scheme value', async () => { + const result = await ctx.runner.run([ + 'tm', + 'upload', + 'sources/simple-tm.csv', + '--language', + 'uk', + '--scheme', + 'en', + ]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("The '--scheme' parameter has an invalid value 'en'"); + }); + + test('rejects an unsupported file extension', async () => { + const result = await ctx.runner.run(['tm', 'upload', 'sources/unsupported.txt', '--language', 'en']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Supported formats: tmx, csv, xlsx'); + }); + + test('rejects --first-line-contains-header for a TMX file', async () => { + const result = await ctx.runner.run([ + 'tm', + 'upload', + 'sources/simple-tm.tmx', + '--language', + 'en', + '--first-line-contains-header', + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--first-line-contains-header' is used only for CSV or XLS/XLSX files"); + }); + + test('requires --language when creating a new translation memory', async () => { + const result = await ctx.runner.run(['tm', 'upload', 'sources/simple-tm.tmx']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--language' is required for creating new translation memory"); + }); + + test('uploads a TMX translation memory, creating it', async () => { + const result = await ctx.runner.run(['tm', 'upload', 'sources/simple-tm.tmx', '--language', 'en']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Imported in #'); + expect(result.stdout).toContain("'Created in Crowdin CLI (simple-tm.tmx)' translation memory"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + tmxId = await findTmId(ctx, 'Created in Crowdin CLI (simple-tm.tmx)'); + }); + + test('uploads a CSV translation memory with an explicit scheme, creating it', async () => { + const result = await ctx.runner.run([ + 'tm', + 'upload', + 'sources/simple-tm.csv', + '--language', + 'uk', + '--scheme', + 'ar=1', + '--scheme', + 'de=2', + '--scheme', + 'en=3', + '--scheme', + 'uk=4', + '--scheme', + 'zh-CN=5', + '--first-line-contains-header', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("'Created in Crowdin CLI (simple-tm.csv)' translation memory"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + csvId = await findTmId(ctx, 'Created in Crowdin CLI (simple-tm.csv)'); + }); + + test('uploads an XLSX translation memory with an explicit scheme, creating it', async () => { + const result = await ctx.runner.run([ + 'tm', + 'upload', + 'sources/simple-tm.xlsx', + '--language', + 'uk', + '--scheme', + 'ar=1', + '--scheme', + 'de=2', + '--scheme', + 'en=3', + '--scheme', + 'uk=4', + '--scheme', + 'zh-CN=5', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("'Created in Crowdin CLI (simple-tm.xlsx)' translation memory"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + xlsxId = await findTmId(ctx, 'Created in Crowdin CLI (simple-tm.xlsx)'); + }); + + test('lists all translation memories in the project', async () => { + const result = await ctx.runner.run(['tm', 'list']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(defaultTmName(ctx)); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-tm.tmx)'); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-tm.csv)'); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-tm.xlsx)'); + // No snapshot: `tm list` covers the whole account, so its output moves between runs. The segment + // counts are cross-checked through the API instead. + const tms = await ctx.client.translationMemoryApi.withFetchAll().listTm(); + const segmentsByName = new Map(tms.data.map((entry) => [entry.data.name, entry.data.segmentsCount])); + expect(segmentsByName.get(defaultTmName(ctx))).toBe(0); + expect(segmentsByName.get('Created in Crowdin CLI (simple-tm.tmx)')).toBe(4); + expect(segmentsByName.get('Created in Crowdin CLI (simple-tm.csv)')).toBe(4); + expect(segmentsByName.get('Created in Crowdin CLI (simple-tm.xlsx)')).toBe(4); + }); + + test('serializes id, name and segment count in the json listing', async () => { + const listed = await runJson<{ id: number; name: string; segmentsCount: number }[]>(ctx, ['tm', 'list']); + const suiteTms = listed.filter((tm) => SUITE_TM_NAMES.includes(tm.name)); + + expect(suiteTms.map((tm) => tm.name).sort()).toEqual([...SUITE_TM_NAMES].sort()); + expect(suiteTms.every((tm) => Object.keys(tm).join() === 'id,name,segmentsCount')).toBe(true); + expect(suiteTms.every((tm) => tm.segmentsCount === 4)).toBe(true); + }); + + test('carries the same listing in the toon output', async () => { + const json = await ctx.runner.run(['tm', 'list', '--output', 'json']); + const toon = await ctx.runner.run(['tm', 'list', '--output', 'toon']); + + expect(toon).toMatchObject({ exitCode: 0 }); + // Two runs over an account-wide listing: a TM another suite adds between them must not read as + // a difference. + expect(suiteEntries(decode(toon.stdout))).toEqual(suiteEntries(json.stdout)); + }); + + test('lists bare names in the plain output', async () => { + const result = await ctx.runner.run(['tm', 'list', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + + const names = result.stdout.split('\n').filter((line) => line.length > 0); + + for (const name of SUITE_TM_NAMES) { + expect(names).toContain(name); + } + }); + + // Like the upload guards, these all fire before the TM is fetched - hence the arbitrary id. + test('rejects a non-numeric translation memory id', async () => { + const result = await ctx.runner.run(['tm', 'download', 'not-a-number']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Translation memory id must be numeric'); + }); + + test('rejects a --to extension that is not a supported format', async () => { + const result = await ctx.runner.run(['tm', 'download', '1', '--to', 'download/out.txt']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Supported formats: tmx, csv, xlsx'); + }); + + test('rejects --source-language-id without --target-language-id', async () => { + const result = await ctx.runner.run(['tm', 'download', '1', '--source-language-id', 'en']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--target-language-id' must be specified along with '--source-language-id'"); + }); + + test('rejects --target-language-id without --source-language-id', async () => { + const result = await ctx.runner.run(['tm', 'download', '1', '--target-language-id', 'uk']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("'--source-language-id' must be specified along with '--target-language-id'"); + }); + + test('reports a translation memory that does not exist', async () => { + const result = await ctx.runner.run(['tm', 'download', String(MISSING_TM_ID)]); + + expect(result.exitCode).toBe(102); + expect(result.stderr).toContain('Not Found'); + }); + + test('downloads the TMX translation memory by id and format', async () => { + const file = 'Created in Crowdin CLI (simple-tm.tmx).tmx'; + + const result = await ctx.runner.run(['tm', 'download', String(tmxId), '--format', 'tmx']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Building translation memory'); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const downloaded = extractTmxSegmentsByLanguage(await Bun.file(join(ctx.workspace, file)).text()); + const expected = extractTmxSegmentsByLanguage(await Bun.file(join(ctx.workspace, 'expected/simple-tm.tmx')).text()); + expect(downloaded).toEqual(expected); + }); + + test('downloads the CSV translation memory by id and format', async () => { + const file = 'Created in Crowdin CLI (simple-tm.csv).csv'; + + const result = await ctx.runner.run(['tm', 'download', String(csvId), '--format', 'csv']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Building translation memory'); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await sortedLines(join(ctx.workspace, file))).toEqual( + await sortedLines(join(ctx.workspace, 'expected/simple-tm.csv')), + ); + }); + + test('downloads the XLSX translation memory by id and format', async () => { + const file = 'Created in Crowdin CLI (simple-tm.xlsx).xlsx'; + + const result = await ctx.runner.run(['tm', 'download', String(xlsxId), '--format', 'xlsx']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Building translation memory'); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(extractXlsxTexts(join(ctx.workspace, file))).toEqual( + extractXlsxTexts(join(ctx.workspace, 'expected/simple-tm.xlsx')), + ); + }); + + test('downloads the TMX translation memory filtered by a language pair', async () => { + const file = 'download/simple-tm_en-uk.tmx'; + + const result = await ctx.runner.run([ + 'tm', + 'download', + String(tmxId), + '--to', + file, + '--source-language-id', + 'en', + '--target-language-id', + 'uk', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + + const downloaded = extractTmxSegmentsByLanguage(await Bun.file(join(ctx.workspace, file)).text()); + const expected = extractTmxSegmentsByLanguage( + await Bun.file(join(ctx.workspace, 'expected/simple-tm_en-uk.tmx')).text(), + ); + expect(downloaded).toEqual(expected); + }); + + test('downloads the TMX translation memory without an explicit format', async () => { + const file = 'Created in Crowdin CLI (simple-tm.tmx).tmx'; + + const result = await ctx.runner.run(['tm', 'download', String(tmxId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test("downloads the project's default translation memory by id", async () => { + const defaultTmId = await findTmId(ctx, defaultTmName(ctx)); + const file = `${defaultTmName(ctx)}.tmx`; + + const result = await ctx.runner.run(['tm', 'download', String(defaultTmId)]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(`'${file}' downloaded successfully`); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('echoes the written path in the plain and json download output', async () => { + const file = 'download/plain-output.tmx'; + const plain = await ctx.runner.run(['tm', 'download', String(tmxId), '--to', file, '--output', 'plain']); + + expect(plain).toMatchObject({ exitCode: 0 }); + expect(plain.stdout.trim()).toBe(file); + + expect(await runJson(ctx, ['tm', 'download', String(tmxId), '--to', file])).toBe(file); + }); + + // Last of the TM-mutating tests: it imports into the TMX memory the download tests read, so it has + // to run after them. + test('uploads into an existing translation memory with --id', async () => { + // A separate fixture on purpose: re-importing `simple-tm.tmx` would dedupe to the same 4 and + // leave the segment count unable to move. + const imported = await runJson<{ id: number; name: string; segmentsCount: number }>(ctx, [ + 'tm', + 'upload', + 'sources/extra-tm.tmx', + '--id', + String(tmxId), + ]); + + expect(imported.id).toBe(tmxId); + expect(imported.name).toBe('Created in Crowdin CLI (simple-tm.tmx)'); + // Refetched after the import: the copy taken before it still reports the original 4. + expect(imported.segmentsCount).toBeGreaterThan(4); + + const tms = await ctx.client.translationMemoryApi.withFetchAll().listTm(); + const matching = tms.data.filter((entry) => entry.data.name === 'Created in Crowdin CLI (simple-tm.tmx)'); + + expect(matching).toHaveLength(1); + }); + + test('accepts a comma-joined --scheme', async () => { + // `--id` keeps this from minting a second TM under an already-taken name. + const imported = await runJson<{ id: number }>(ctx, [ + 'tm', + 'upload', + 'sources/simple-tm.csv', + '--id', + String(csvId), + '--scheme', + 'ar=1,de=2,en=3,uk=4,zh-CN=5', + '--first-line-contains-header', + ]); + + expect(imported.id).toBe(csvId); + }); + + test('rejects a non-numeric --id on upload', async () => { + const result = await ctx.runner.run(['tm', 'upload', 'sources/simple-tm.tmx', '--id', 'not-a-number']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('Translation memory id must be numeric'); + }); + + test('lists translation memories authenticating via -T against a config without an api_token', async () => { + await switchConfig(ctx, 'without-token'); + + const result = await ctx.runner.run(['tm', 'list', '-T', ctx.env.token as string]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain(defaultTmName(ctx)); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-tm.tmx)'); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-tm.csv)'); + expect(result.stdout).toContain('Created in Crowdin CLI (simple-tm.xlsx)'); + }); +}); diff --git a/tests/e2e/suites/translation-patterns.test.ts b/tests/e2e/suites/translation-patterns.test.ts new file mode 100644 index 000000000..6eff76637 --- /dev/null +++ b/tests/e2e/suites/translation-patterns.test.ts @@ -0,0 +1,136 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { captureAndClear, expectRestored } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Ten file groups, + * one per `translation:` placeholder token plus two combining `%original_path%` with a language + * token, exercised through upload sources -> dryrun/real upload translations -> dryrun/real + * download translations -> `config translations`, against target languages `uk` and `zh-CN`. + */ + +// The local translation path each of the ten file groups resolves to, for both target languages. +const TRANSLATION_PATHS = [ + 'android_code/uk-rUA/android.xml', + 'android_code/zh-rCN/android.xml', + 'doubled_asterisk/res/values-uk/android.xml', + 'doubled_asterisk/res/values-zh/android.xml', + 'language/Ukrainian/android.xml', + 'language/Chinese Simplified/android.xml', + 'locale/uk-UA/android.xml', + 'locale/zh-CN/android.xml', + 'locale_with_underscore/uk_UA/android.xml', + 'locale_with_underscore/zh_CN/android.xml', + 'osx_code/uk.lproj/android.xml', + 'osx_code/zh-Hans.lproj/android.xml', + 'osx_locale/uk/android.xml', + 'osx_locale/zh-Hans/android.xml', + 'three_letters_code/ukr/android.xml', + 'three_letters_code/zho/android.xml', + 'two_letters_code/uk/android.xml', + 'two_letters_code/zh/android.xml', + 'two_letters_code_with_original_path/two_letters_code_with_original_path-uk/android.xml', + 'two_letters_code_with_original_path/two_letters_code_with_original_path-zh/android.xml', +]; + +describe('translation patterns', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('translation-patterns', { sourceLanguageId: 'en', targetLanguageIds: ['uk', 'zh-CN'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources across every placeholder-pattern file group', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + for (const path of [ + 'android_code/android.xml', + 'doubled_asterisk/res/values/android.xml', + 'language/android.xml', + 'locale/android.xml', + 'locale_with_underscore/android.xml', + 'osx_code/android.xml', + 'osx_locale/android.xml', + 'three_letters_code/android.xml', + 'two_letters_code/android.xml', + 'two_letters_code_with_original_path/android.xml', + ]) { + expect(result.stdout).toContain(`File '${path}'`); + } + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation upload across every placeholder-pattern file group', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + + for (const path of TRANSLATION_PATHS) { + expect(result.stdout).toContain(`File '${path}' would be queued for translations import`); + } + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations across every placeholder-pattern file group', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + + for (const path of TRANSLATION_PATHS) { + expect(result.stdout).toContain(`File '${path}'`); + } + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the translation download across every placeholder-pattern file group', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + + for (const path of TRANSLATION_PATHS) { + expect(result.stdout).toContain(path); + } + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations across every placeholder-pattern file group', async () => { + // Each of these paths is where an upload fixture already sits; clear them so the existence check + // below cannot pass on a stale file. + const captured = await captureAndClear(ctx.workspace, ...TRANSLATION_PATHS); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + + for (const path of TRANSLATION_PATHS) { + expect(result.stdout).toContain(`File '${path}' extracted`); + } + + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectRestored(ctx.workspace, captured); + }); + + test('lists configured translation files across every placeholder-pattern file group', async () => { + const result = await ctx.runner.run(['config', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + + for (const path of TRANSLATION_PATHS) { + expect(result.stdout).toContain(path); + } + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/translation-replace.test.ts b/tests/e2e/suites/translation-replace.test.ts new file mode 100644 index 000000000..3a938152c --- /dev/null +++ b/tests/e2e/suites/translation-replace.test.ts @@ -0,0 +1,238 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesExist, expectFilesMatch, listFilesRecursively } from '../helpers/files.ts'; +import { projectFilePaths } from '../helpers/lookup.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Exercises upload + * sources / upload translations / download translations over a nested Android-resources tree, on the + * default branch and on a new one - the real subject being a second `upload sources` to a file that + * already exists, and how translations behave around it. + * + * The branch phase uploads translations with no `-b test-branch`, so the branch's own sources never receive + * translations and the branch download asserts existence only, not content. + * + * The translation content differs between it and uk, so a content assertion + * can tell the languages apart. + */ + +const MASTER_SOURCE_FILE_PATHS = [ + '/en/src/main/resources/android.xml', + '/en/src/main/resources/org/crowdin/android.xml', + '/en/src/main/resources/org/crowdin/strings.xml', +].sort(); + +const BRANCH_SOURCE_FILE_PATHS = MASTER_SOURCE_FILE_PATHS.map((path) => `/test-branch${path}`).sort(); + +const EXPECTED_LOCAL_FILES_AFTER_DOWNLOAD = [ + 'en/src/main/resources/android.xml', + 'en/src/main/resources/org/crowdin/android.xml', + 'en/src/main/resources/org/crowdin/strings.xml', + 'it/src/main/resources/android.xml', + 'it/src/main/resources/org/crowdin/android.xml', + 'it/src/main/resources/org/crowdin/strings.xml', + 'uk/src/main/resources/android.xml', + 'uk/src/main/resources/org/crowdin/android.xml', + 'uk/src/main/resources/org/crowdin/strings.xml', +].sort(); + +describe('translation replace', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('translation-replace', { targetLanguageIds: ['it', 'uk'] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources, creating the nested directory hierarchy', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'en'"); + expect(result.stdout).toContain("Directory 'en/src'"); + expect(result.stdout).toContain("Directory 'en/src/main'"); + expect(result.stdout).toContain("Directory 'en/src/main/resources'"); + expect(result.stdout).toContain("Directory 'en/src/main/resources/org'"); + expect(result.stdout).toContain("Directory 'en/src/main/resources/org/crowdin'"); + expect(result.stdout).toContain("File 'en/src/main/resources/android.xml'"); + expect(result.stdout).toContain("File 'en/src/main/resources/org/crowdin/android.xml'"); + expect(result.stdout).toContain("File 'en/src/main/resources/org/crowdin/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual(MASTER_SOURCE_FILE_PATHS); + }); + + test('updates the existing sources without creating anything new', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).not.toContain('created'); + expect(result.stdout).toContain("File 'en/src/main/resources/android.xml'"); + expect(result.stdout).toContain("File 'en/src/main/resources/org/crowdin/android.xml'"); + expect(result.stdout).toContain("File 'en/src/main/resources/org/crowdin/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual(MASTER_SOURCE_FILE_PATHS); + }); + + test('previews uploading translations as a dry run', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'it/src/main/resources/android.xml' would be queued for translations import"); + expect(result.stdout).toContain( + "File 'uk/src/main/resources/org/crowdin/strings.xml' would be queued for translations import", + ); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations for it and uk', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'it/src/main/resources/android.xml'"); + expect(result.stdout).toContain("Importing translations for file 'uk/src/main/resources/org/crowdin/strings.xml'"); + expect(result.stdout).toContain("File 'it/src/main/resources/android.xml'"); + expect(result.stdout).toContain("File 'it/src/main/resources/org/crowdin/android.xml'"); + expect(result.stdout).toContain("File 'it/src/main/resources/org/crowdin/strings.xml'"); + expect(result.stdout).toContain("File 'uk/src/main/resources/android.xml'"); + expect(result.stdout).toContain("File 'uk/src/main/resources/org/crowdin/android.xml'"); + expect(result.stdout).toContain("File 'uk/src/main/resources/org/crowdin/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews downloading translations as a dry run', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('it/src/main/resources/android.xml'); + expect(result.stdout).toContain('uk/src/main/resources/org/crowdin/strings.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations, overwriting the local it/uk trees', async () => { + // Prove the download recreates these from the server rather than finding them on disk. + await rm(join(ctx.workspace, 'files', 'it'), { recursive: true, force: true }); + await rm(join(ctx.workspace, 'files', 'uk'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'it/src/main/resources/android.xml' extracted"); + expect(result.stdout).toContain("File 'uk/src/main/resources/org/crowdin/strings.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist( + ctx.workspace, + 'files/it/src/main/resources/android.xml', + 'files/it/src/main/resources/org/crowdin/android.xml', + 'files/it/src/main/resources/org/crowdin/strings.xml', + 'files/uk/src/main/resources/android.xml', + 'files/uk/src/main/resources/org/crowdin/android.xml', + 'files/uk/src/main/resources/org/crowdin/strings.xml', + ); + + await expectFilesMatch( + ctx.workspace, + 'files', + 'expected', + 'it/src/main/resources/android.xml', + 'it/src/main/resources/org/crowdin/strings.xml', + 'uk/src/main/resources/android.xml', + 'uk/src/main/resources/org/crowdin/strings.xml', + ); + + expect(await listFilesRecursively(join(ctx.workspace, 'files'))).toEqual(EXPECTED_LOCAL_FILES_AFTER_DOWNLOAD); + }); + + // --- Branch coverage from here: the SAME local tree uploaded again under a brand-new branch. --- + + test('uploads sources to a brand-new branch, creating the directory hierarchy again', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + // Directories are per-branch entities in Crowdin, so the branch gets its own fresh set; the paths + // carry no "test-branch/" prefix. + expect(result.stdout).toContain("Directory 'en'"); + expect(result.stdout).toContain("Directory 'en/src/main/resources/org/crowdin'"); + expect(result.stdout).toContain("File 'en/src/main/resources/android.xml'"); + expect(result.stdout).toContain("File 'en/src/main/resources/org/crowdin/android.xml'"); + expect(result.stdout).toContain("File 'en/src/main/resources/org/crowdin/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual([...MASTER_SOURCE_FILE_PATHS, ...BRANCH_SOURCE_FILE_PATHS].sort()); + }); + + test('updates sources on the branch (branch already exists)', async () => { + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).not.toContain('created'); + expect(result.stdout).toContain("File 'en/src/main/resources/android.xml'"); + expect(result.stdout).toContain("File 'en/src/main/resources/org/crowdin/android.xml'"); + expect(result.stdout).toContain("File 'en/src/main/resources/org/crowdin/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + expect(await projectFilePaths(ctx)).toEqual([...MASTER_SOURCE_FILE_PATHS, ...BRANCH_SOURCE_FILE_PATHS].sort()); + }); + + test('previews uploading translations as a dry run on the branch', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--dryrun', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + // The config has no branch-name placeholder, so this reads identically to the non-branch dry run. + expect(result.stdout).toContain("File 'it/src/main/resources/android.xml' would be queued for translations import"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + // No `-b` (see the file header): this replaces the translations on the MASTER files. + test('re-uploads translations to the already-translated master files (no -b)', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Importing translations for file 'it/src/main/resources/android.xml'"); + expect(result.stdout).toContain("File 'it/src/main/resources/android.xml'"); + expect(result.stdout).toContain("File 'uk/src/main/resources/org/crowdin/strings.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews downloading translations on the branch as a dry run', async () => { + const result = await ctx.runner.run(['download', 'translations', '-b', 'test-branch', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('it/src/main/resources/android.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations on the branch', async () => { + await rm(join(ctx.workspace, 'files', 'it'), { recursive: true, force: true }); + await rm(join(ctx.workspace, 'files', 'uk'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'it/src/main/resources/android.xml' extracted"); + expect(result.stdout).toContain("File 'uk/src/main/resources/org/crowdin/strings.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + // Existence only: this build covers the branch's own files, which never received translations. + await expectFilesExist( + ctx.workspace, + 'files/it/src/main/resources/android.xml', + 'files/it/src/main/resources/org/crowdin/android.xml', + 'files/it/src/main/resources/org/crowdin/strings.xml', + 'files/uk/src/main/resources/android.xml', + 'files/uk/src/main/resources/org/crowdin/android.xml', + 'files/uk/src/main/resources/org/crowdin/strings.xml', + ); + + // The local landing path never carries the branch name, so the file set is unchanged. + expect(await listFilesRecursively(join(ctx.workspace, 'files'))).toEqual(EXPECTED_LOCAL_FILES_AFTER_DOWNLOAD); + }); +}); diff --git a/tests/e2e/suites/translations-not-match.test.ts b/tests/e2e/suites/translations-not-match.test.ts new file mode 100644 index 000000000..f01db1f25 --- /dev/null +++ b/tests/e2e/suites/translations-not-match.test.ts @@ -0,0 +1,273 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { decode } from '@toon-format/toon'; +import { expectFilesExist, expectFilesMatch } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, switchConfig, teardownSuite } from '../helpers/suite.ts'; + +/** + * The mismatch is manufactured: `sources/java.properties` is created straight through the API, so it + * carries no `exportPattern`, while the `sources/*.xml` files are uploaded through the CLI and get + * one. The config's `source:` pattern is then narrowed to a single file before each download, so the + * build archive holds exports the narrowed config cannot map locally - the `reportOmittedFiles` path + * this suite exists to exercise. + * + * Two behaviors worth knowing while reading the assertions: + * - Branch upload prints no branch-creation message and uses unprefixed local paths. + * - For a file with no `exportPattern`, the archive path depends on how many languages the build + * targets: an all-language build needs the `<languageId>/<name>` fallback to disambiguate, a + * single-language build does not, so the entry is the bare filename. + */ + +describe('translations not match', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('translations-not-match'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources, alongside a directly-uploaded file the config never covers', async () => { + // Added straight through the API, so it gets no per-file `exportPattern` and no config can ever + // map it locally - the permanent "extra" source the mismatch scenario needs. + const content = new Uint8Array(await Bun.file(join(ctx.workspace, 'sources/java.properties')).arrayBuffer()); + const storage = await ctx.client.uploadStorageApi.addStorage('java.properties', content); + await ctx.client.sourceFilesApi.createFile(ctx.project.id, { storageId: storage.data.id, name: 'java.properties' }); + + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain("File 'sources/1_android.xml'"); + expect(result.stdout).toContain("File 'sources/2_android.xml'"); + expect(result.stdout).toContain("File 'sources/3_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('attempts to upload translations for all languages (none exist locally)', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("File 'translations/it/1_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/it/2_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/it/3_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/uk/1_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/uk/2_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/uk/3_android.xml' does not exist in the specified location"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('attempts to upload translations for a single specified language (uk)', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-l', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain("File 'translations/uk/1_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/uk/2_android.xml' does not exist in the specified location"); + expect(result.stderr).toContain("File 'translations/uk/3_android.xml' does not exist in the specified location"); + expect(result.stdout).not.toContain('translations/it/'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test.each(['json', 'toon'] as const)( + 'keeps stdout a parseable %s document while every warning goes to stderr', + async (format) => { + const result = await ctx.runner.run(['upload', 'translations', '-l', 'uk', '--output', format]); + + expect(result).toMatchObject({ exitCode: 0 }); + + // stderr is a stream of records, not one document: json separates them with a newline, toon + // with a blank line. Both escape a newline inside a message, so the split is unambiguous. + const records = result.stderr + .trim() + .split(format === 'json' ? '\n' : '\n\n') + .map( + (record) => (format === 'json' ? JSON.parse(record) : decode(record)) as { level: string; message: string }, + ); + + expect(records.map((record) => record.level)).toEqual(['warning', 'warning', 'warning']); + expect(records.map((record) => record.message).sort()).toEqual([ + "File 'translations/uk/1_android.xml' does not exist in the specified location", + "File 'translations/uk/2_android.xml' does not exist in the specified location", + "File 'translations/uk/3_android.xml' does not exist in the specified location", + ]); + + const uploaded = (format === 'json' ? JSON.parse(result.stdout) : decode(result.stdout)) as { + path: string; + action: string; + reason: string | null; + }[]; + + expect(uploaded.map((file) => file.path).sort()).toEqual([ + 'translations/uk/1_android.xml', + 'translations/uk/2_android.xml', + 'translations/uk/3_android.xml', + ]); + expect(uploaded.every((file) => file.action === 'skipped' && file.reason === 'not found locally')).toBe(true); + }, + ); + + test('previews downloading translations once the config narrows to a single source file (dry run)', async () => { + await switchConfig(ctx, 'single-file'); + + const result = await ctx.runner.run(['download', 'translations', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('translations/it/1_android.xml'); + expect(result.stdout).toContain('translations/uk/1_android.xml'); + expect(result.stdout).not.toContain('2_android.xml'); + expect(result.stdout).not.toContain('3_android.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('previews the same narrowed dry run as a tree', async () => { + const result = await ctx.runner.run(['download', 'translations', '--dryrun', '--tree']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for real, warning about the sources the narrowed config no longer covers', async () => { + const result = await ctx.runner.run(['download', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain( + "Downloaded translations don't match the current project configuration. The translations for the " + + 'following sources will be omitted (use --verbose to get the list of the omitted translations):', + ); + expect(result.stdout).toContain('sources/2_android.xml (2)'); + expect(result.stdout).toContain('sources/3_android.xml (2)'); + expect(result.stdout).toContain('java.properties (2)'); + expect(result.stdout).toContain('Visit the https://crowdin.github.io/crowdin-cli/faq for more details'); + // --verbose is not set here, so the omitted archive paths themselves must NOT be listed. + expect(result.stdout).not.toContain('translations/it/2_android.xml'); + expect(result.stdout).not.toContain('translations/it/3_android.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations', 'expected', 'it/1_android.xml', 'uk/1_android.xml'); + }); + + test('downloads translations again with --verbose, listing the omitted translation paths', async () => { + const result = await ctx.runner.run(['download', 'translations', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('sources/2_android.xml (2)'); + expect(result.stdout).toContain('sources/3_android.xml (2)'); + expect(result.stdout).toContain('java.properties (2)'); + // --verbose additionally lists the concrete omitted archive paths under each source. The XML + // sources carry the `translations/%two_letters_code%/%original_file_name%` exportPattern set at + // upload time; `java.properties` (no exportPattern of its own) falls back to a bare + // `<languageId>/<name>` path. + expect(result.stdout).toContain('translations/it/2_android.xml'); + expect(result.stdout).toContain('translations/uk/2_android.xml'); + expect(result.stdout).toContain('translations/it/3_android.xml'); + expect(result.stdout).toContain('translations/uk/3_android.xml'); + expect(result.stdout).toContain('it/java.properties'); + expect(result.stdout).toContain('uk/java.properties'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for a single specified language (uk)', async () => { + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations', '-l', 'uk']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('sources/2_android.xml (1)'); + expect(result.stdout).toContain('sources/3_android.xml (1)'); + expect(result.stdout).toContain('java.properties (1)'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations', 'expected', 'uk/1_android.xml'); + }); + + test('downloads translations for a single specified language (uk) with --verbose', async () => { + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); + + const result = await ctx.runner.run(['download', 'translations', '-l', 'uk', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('sources/2_android.xml (1)'); + expect(result.stdout).toContain('sources/3_android.xml (1)'); + expect(result.stdout).toContain('java.properties (1)'); + expect(result.stdout).toContain('translations/uk/2_android.xml'); + expect(result.stdout).toContain('translations/uk/3_android.xml'); + // Unlike the all-languages --verbose run above, a single-language build has no ambiguity to + // resolve for a file with no custom exportPattern: the server's archive entry for + // `java.properties` is the bare name, with no `%two_letters_code%`-style prefix. + expect(result.stdout).toContain('\t\t- java.properties'); + expect(result.stdout).not.toContain('uk/java.properties'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads sources to a brand-new branch', async () => { + await switchConfig(ctx, 'multi-file'); + + const result = await ctx.runner.run(['upload', 'sources', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Directory 'sources'"); + expect(result.stdout).toContain("File 'sources/1_android.xml'"); + expect(result.stdout).toContain("File 'sources/2_android.xml'"); + expect(result.stdout).toContain("File 'sources/3_android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations for the branch, with the same configuration mismatch', async () => { + await rm(join(ctx.workspace, 'translations'), { recursive: true, force: true }); + await switchConfig(ctx, 'single-file'); + + const result = await ctx.runner.run(['download', 'translations', '-b', 'test-branch']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('sources/2_android.xml (2)'); + expect(result.stdout).toContain('sources/3_android.xml (2)'); + // `java.properties` lives outside any branch, so a branch-scoped build never includes it - it + // cannot show up in this report the way it does for the non-branch download above. + expect(result.stdout).not.toContain('java.properties'); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesMatch(ctx.workspace, 'translations', 'expected', 'it/1_android.xml', 'uk/1_android.xml'); + }); + + test('downloads translations for the branch with --verbose', async () => { + const result = await ctx.runner.run(['download', 'translations', '-b', 'test-branch', '--verbose']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('sources/2_android.xml (2)'); + expect(result.stdout).toContain('sources/3_android.xml (2)'); + expect(result.stdout).not.toContain('java.properties'); + expect(result.stdout).toContain('translations/it/2_android.xml'); + expect(result.stdout).toContain('translations/uk/2_android.xml'); + expect(result.stdout).toContain('translations/it/3_android.xml'); + expect(result.stdout).toContain('translations/uk/3_android.xml'); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('suppresses the mismatch report with --ignore-match', async () => { + const result = await ctx.runner.run(['download', 'translations', '--ignore-match']); + + expect(result).toMatchObject({ exitCode: 0 }); + // The report the tests above assert in full is exactly what this flag exists to silence. + expect(result.stderr).not.toContain("Downloaded translations don't match the current project configuration"); + expect(result.stdout).not.toContain('sources/2_android.xml (2)'); + expect(result.stdout).not.toContain('java.properties (2)'); + + // Silencing the report must not change what lands on disk. + await expectFilesExist(ctx.workspace, 'translations/it/1_android.xml', 'translations/uk/1_android.xml'); + }); + + test('fails when the build maps to no local file at all', async () => { + await switchConfig(ctx, 'no-sources'); + + const result = await ctx.runner.run(['download', 'translations']); + + // The hard-error arm of the same check: with --skip-untranslated-files this is only a warning, + // which export-options.test.ts covers. + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Couldn't find any file to download"); + }); +}); diff --git a/tests/e2e/suites/update-option.test.ts b/tests/e2e/suites/update-option.test.ts new file mode 100644 index 000000000..e8f640179 --- /dev/null +++ b/tests/e2e/suites/update-option.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { findFileId, translationCount } from '../helpers/lookup.ts'; +import { runJson, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers the `update_option` config key (`UPDATE_OPTION_MAP` in `lib/config.ts`). Unlike the other + * per-file keys it is not stored on the file, so it cannot be read back - it only takes effect while + * an *existing* file is being replaced, and + * only for strings whose text changed. Proving it therefore needs the whole cycle: upload, + * translate, edit the source, re-upload. + * + * The config carries three groups so the result is a contrast rather than a claim. `kept.json` + * declares `update_as_unapproved` (the API's `keep_translations`), `approved.json` declares + * `update_without_changes` and `plain.json` declares nothing; all are translated and then edited + * identically, so whatever difference appears at the end is the key's doing. + */ +const LANGUAGE = 'uk'; + +describe('update_option', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('update-option', { targetLanguageIds: [LANGUAGE] }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + async function findString(fileName: string): Promise<number> { + const fileId = await findFileId(ctx, `/sources/${fileName}`); + const strings = await ctx.client.sourceStringsApi.withFetchAll().listProjectStrings(ctx.project.id, { fileId }); + const match = strings.data[0]; + + if (!match) { + throw new Error(`No strings found in '${fileName}'`); + } + + return match.data.id; + } + + async function approvalCount(stringId: number): Promise<number> { + const response = await ctx.client.stringTranslationsApi.listTranslationApprovals(ctx.project.id, { + stringId, + languageId: LANGUAGE, + }); + + return response.data.length; + } + + test('uploads both sources and translates them', async () => { + const upload = await ctx.runner.run(['upload', 'sources']); + + expect(upload).toMatchObject({ exitCode: 0 }); + + for (const fileName of ['kept.json', 'plain.json', 'approved.json']) { + const stringId = await findString(fileName); + + const translation = await ctx.client.stringTranslationsApi.addTranslation(ctx.project.id, { + stringId, + languageId: LANGUAGE, + text: 'Привіт', + }); + + expect(await translationCount(ctx, stringId, LANGUAGE)).toBe(1); + + // Only the third file's option claims to carry approvals through an update. + if (fileName === 'approved.json') { + await ctx.client.stringTranslationsApi.addApproval(ctx.project.id, { + translationId: translation.data.id, + }); + + expect(await approvalCount(stringId)).toBe(1); + } + } + }); + + test('keeps the translation of a changed string only where update_option asks for it', async () => { + // Same edit to every file: the string's text changes, which is the only case the option + // governs - an untouched string keeps its translation either way. + for (const fileName of ['kept.json', 'plain.json', 'approved.json']) { + await Bun.write(join(ctx.workspace, 'sources', fileName), '{\n "greeting": "Hello there"\n}\n'); + } + + const uploaded = await runJson<{ path: string; action: string }[]>(ctx, ['upload', 'sources']); + + // Assert the update actually happened before reading translations off it. A run where the API + // did not replace the files would otherwise fail further down as a translation-count mismatch, + // which says nothing about why. + + expect(uploaded.map((file) => file.action)).toEqual(['updated', 'updated', 'updated']); + + // The string ids change with the text, so look them up again rather than reusing the old ones. + const keptTranslations = await translationCount(ctx, await findString('kept.json'), LANGUAGE); + const plainTranslations = await translationCount(ctx, await findString('plain.json'), LANGUAGE); + + expect(keptTranslations).toBe(1); + expect(plainTranslations).toBe(0); + }); + + test('carries the approval through as well with update_without_changes', async () => { + // The other half of UPDATE_OPTION_MAP: keep_translations_and_approvals, where the translation + // survives the edit still approved rather than reset to unapproved. + const stringId = await findString('approved.json'); + + expect(await translationCount(ctx, stringId, LANGUAGE)).toBe(1); + expect(await approvalCount(stringId)).toBe(1); + }); +}); diff --git a/tests/e2e/suites/upload-single-file.test.ts b/tests/e2e/suites/upload-single-file.test.ts new file mode 100644 index 000000000..0e0148dcb --- /dev/null +++ b/tests/e2e/suites/upload-single-file.test.ts @@ -0,0 +1,208 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Uploading one file via a + * single-file `-s`/`-t` pair, alone and in combination with a config file, `--dest`, an empty file, + * an empty `files:` group, a branch, and `--preserve-hierarchy`. + * + * Two rules shape how the whole suite reads: + * + * 1. A CLI `--source`+`--translation` pair REPLACES `config.files` rather than merging into it, so + * every test passing both uploads the same single file whether or not a config file is present - + * the config only supplies credentials and paths. + * 2. `preserve_hierarchy` is true for every upload here (the fixture's own value; `noConfig: true` + * only drops the `-c` flag, and the config is still auto-discovered). So the local `sources/` + * prefix reaches the project path from the first test on, the `sources` directory is created + * once and never again, and the final test's explicit `--preserve-hierarchy` changes nothing - + * it only confirms the explicit flag agrees. + */ +describe('upload single file', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('upload-single-file'); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads a single file via short flag params with no config file', async () => { + const result = await ctx.runner.run( + [ + 'upload', + 'sources', + '-s', + 'sources/1_android.xml', + '-t', + '/translations/%two_letters_code%/%original_file_name%', + '-i', + String(ctx.project.id), + '-T', + ctx.env.token as string, + '--base-url', + 'https://api.crowdin.com', + '--no-progress', + '--no-colors', + ], + { noConfig: true }, + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + // Success echoes the project path; with no `--dest` here it equals the local one. + expect(result.stdout).toContain("File 'sources/1_android.xml'"); + // First upload in the suite, so the `sources` directory is created here and nowhere else. + expect(result.stdout).toContain("Directory 'sources'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads the same file via long flag params with no config file', async () => { + const result = await ctx.runner.run( + [ + 'upload', + 'sources', + '--source', + 'sources/1_android.xml', + '--translation', + '/translations/%two_letters_code%/%original_file_name%', + '--project-id', + String(ctx.project.id), + '--token', + ctx.env.token as string, + '--base-url', + 'https://api.crowdin.com', + '--no-progress', + '--no-colors', + ], + { noConfig: true }, + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain("File 'sources/1_android.xml'"); + // Both the directory and the file already exist, so this is an update. + expect(result.stdout).not.toContain("Directory 'sources'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads the same file combined with a config file, replacing its files entry', async () => { + // Same single-file upload as above; only the credentials now come from the config file. + const result = await ctx.runner.run([ + 'upload', + 'sources', + '-s', + 'sources/1_android.xml', + '-t', + '/translations/%locale%/%original_file_name%', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain("File 'sources/1_android.xml'"); + expect(result.stdout).not.toContain("Directory 'sources'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads the same file combined with a config file and an explicit --dest', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '-s', + 'sources/1_android.xml', + '-t', + '/translations/%locale%/%original_file_name%', + '--dest', + '/sources/androidDest.xml', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain("File 'sources/androidDest.xml'"); + expect(result.stdout).not.toContain("Directory 'sources'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + + const files = await ctx.client.sourceFilesApi.listProjectFiles(ctx.project.id, { recursion: '1' }); + const destFile = files.data.find((file) => file.data.path === '/sources/androidDest.xml'); + expect(destFile).toBeDefined(); + }); + + test('uploads an empty file, which is skipped with a warning', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '-s', + 'sources/empty_android.xml', + '-t', + '/translations/%locale%/%original_file_name%', + ]); + + // A skipped file warns without failing the run. + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stderr).toContain("File 'sources/empty_android.xml' was skipped since it is empty"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test("uploads every file from the config's own file group, skipping the empty ones", async () => { + // The one test driven by the fixture's own `files:` group: 1_android.xml updates, 2_android.xml + // is created, and the two empty files are skipped with a warning each. + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stderr).toContain("File 'sources/empty_android.xml' was skipped since it is empty"); + expect(result.stderr).toContain("File 'sources/empty_android2.xml' was skipped since it is empty"); + expect(result.stdout).toContain("File 'sources/1_android.xml'"); + expect(result.stdout).toContain("File 'sources/2_android.xml'"); + expect(result.stdout).not.toContain("Directory 'sources'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads an empty file to a new branch, still skipped with a warning', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '-b', + 'test', + '-s', + 'sources/empty_android.xml', + '-t', + '/translations/%locale%/%original_file_name%', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + // The branch is created silently, and the empty-file warning carries no branch prefix. + expect(result.stderr).toContain("File 'sources/empty_android.xml' was skipped since it is empty"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads the same file again with --preserve-hierarchy explicitly set', async () => { + // Matches the value already in effect. + const result = await ctx.runner.run([ + 'upload', + 'sources', + '-s', + 'sources/1_android.xml', + '-t', + '/translations/%locale%/%original_file_name%', + '--preserve-hierarchy', + ]); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain('Fetching project info'); + expect(result.stdout).toContain("File 'sources/1_android.xml'"); + + expect(normalize(result.stdout)).toMatchSnapshot(); + }); +}); diff --git a/tests/e2e/suites/upload-sources.test.ts b/tests/e2e/suites/upload-sources.test.ts new file mode 100644 index 000000000..c4f84bff5 --- /dev/null +++ b/tests/e2e/suites/upload-sources.test.ts @@ -0,0 +1,201 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { + createExtraProject, + runJson, + type SuiteContext, + setupSuite, + switchConfig, + teardownSuite, +} from '../helpers/suite.ts'; + +/** + * Covers the flags `upload sources` owns (`cli/commands/upload/UploadSourcesCommand.ts`): `--cache` + * and the guards that fire only against a string-based project. + * + * The checksum cache (`lib/upload/sourceCache.ts`) can only be asserted across several runs, so the + * tests below run in sequence and share state. + * + * Not attempted: the no-manager-access guard (a project the token can read but not manage) and + * `File ... is currently being updated`, which is a race against a concurrent update rather than + * something a test can arrange. + */ +const SOURCE_PATHS = ['sources/alpha.json', 'sources/beta.json']; + +interface UploadedFile { + path: string; + action: string; + reason: string | null; +} + +describe('upload sources', () => { + let ctx: SuiteContext; + let stringsBasedProjectId: number; + + beforeAll(async () => { + ctx = await setupSuite('upload-sources', { targetLanguageIds: ['uk'] }); + // The string-based guards need a project of that kind; this suite's own is file-based. + stringsBasedProjectId = await createExtraProject(ctx, { suite: 'upload-sources-strings', stringsBased: true }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + async function uploadJson(args: string[] = []): Promise<UploadedFile[]> { + return runJson<UploadedFile[]>(ctx, ['upload', 'sources', ...args]); + } + + function cachePath(): string { + return join(ctx.workspace, '.crowdin', 'cache.json'); + } + + test('uploads through the `push` alias', async () => { + const result = await ctx.runner.run(['push']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/alpha.json'"); + expect(result.stdout).toContain("File 'sources/beta.json'"); + }); + + test('uploads sources when no subcommand is given', async () => { + const result = await ctx.runner.run(['upload']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'sources/alpha.json'"); + }); + + test('lists only the written paths with --output plain', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--output', 'plain']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout.split('\n').filter(Boolean).sort()).toEqual(SOURCE_PATHS); + }); + + test('writes no cache file unless --cache is given', async () => { + expect(await Bun.file(cachePath()).exists()).toBe(false); + }); + + test('seeds the checksum cache on the first --cache run', async () => { + const uploaded = await uploadJson(['--cache']); + + // Nothing cached yet, so nothing is skipped. The action is 'updated' rather than 'created': + // the tests above already put both files in the project. + expect(uploaded.every((file) => file.action === 'updated')).toBe(true); + + const cache = (await Bun.file(cachePath()).json()) as { sourceHashes: Record<string, string> }; + + expect(Object.keys(cache.sourceHashes).sort()).toEqual(SOURCE_PATHS); + // sha256, so 64 hex characters. + expect(Object.values(cache.sourceHashes).every((hash) => /^[0-9a-f]{64}$/.test(hash))).toBe(true); + }); + + test('skips every unchanged file on the second --cache run', async () => { + const uploaded = await uploadJson(['--cache']); + + expect(uploaded.map((file) => file.path).sort()).toEqual(SOURCE_PATHS); + expect(uploaded.every((file) => file.action === 'skipped' && file.reason === 'up to date')).toBe(true); + }); + + test('lifts the skip for the one file whose content changed', async () => { + await Bun.write(join(ctx.workspace, 'sources/alpha.json'), '{\n "greeting": "Hello again"\n}\n'); + + const uploaded = await uploadJson(['--cache']); + const byPath = new Map(uploaded.map((file) => [file.path, file])); + + expect(byPath.get('sources/alpha.json')?.action).toBe('updated'); + expect(byPath.get('sources/beta.json')?.action).toBe('skipped'); + }); + + test('ignores --cache under --dryrun, leaving the cache untouched', async () => { + const before = await Bun.file(cachePath()).text(); + + const result = await ctx.runner.run(['upload', 'sources', '--cache', '--dryrun']); + + expect(result).toMatchObject({ exitCode: 0 }); + // A dry run uploads nothing, so recording checksums for it would make the next real run skip + // files it never sent. + expect(await Bun.file(cachePath()).text()).toBe(before); + }); + + test('starts from an empty cache when the cache file is unreadable', async () => { + await Bun.write(cachePath(), 'not json at all'); + + const result = await ctx.runner.run(['upload', 'sources', '--cache', '--output', 'json']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stderr).toContain('Failed to read cache file'); + + const uploaded = JSON.parse(result.stdout) as UploadedFile[]; + + expect(uploaded.every((file) => file.action !== 'skipped')).toBe(true); + }); + + // The four warning tests below assert the warning only. Uploading these sources into a + // string-based project succeeds or reports per-file errors depending on what the previous run + // left behind, so the exit code is not stable enough to assert - the warning is the contract. + test('warns that excluded languages do not apply to a string-based project', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--excluded-language', + 'uk', + '--branch', + 'main', + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.stderr).toContain("'excluded-languages' option can not be used for string-based projects"); + }); + + test('warns that delete-obsolete does not apply to a string-based project', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--delete-obsolete', + '--branch', + 'main', + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.stderr).toContain("'delete-obsolete' option can not be used for string-based projects"); + }); + + test('warns that no-auto-update does not apply to a string-based project', async () => { + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--no-auto-update', + '--branch', + 'main', + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.stderr).toContain("'no-auto-update' option can not be used for string-based projects"); + }); + + test('requires a branch for a string-based project', async () => { + const result = await ctx.runner.run(['upload', 'sources', '--project-id', String(stringsBasedProjectId)]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('A branch is required to upload sources for a strings-based project'); + }); + + test('warns that a configured context does not apply to a string-based project', async () => { + await switchConfig(ctx, 'with-context'); + + const result = await ctx.runner.run([ + 'upload', + 'sources', + '--branch', + 'main', + '--project-id', + String(stringsBasedProjectId), + ]); + + expect(result.stderr).toContain('Context can not be used for string-based projects'); + }); +}); diff --git a/tests/e2e/suites/upload-translations.test.ts b/tests/e2e/suites/upload-translations.test.ts new file mode 100644 index 000000000..3c4117c53 --- /dev/null +++ b/tests/e2e/suites/upload-translations.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { translationCount } from '../helpers/lookup.ts'; +import { createExtraProject, type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Covers the flags `upload translations` owns (`cli/commands/upload/UploadTranslationsCommand.ts`): + * two import flags and two guards. + * + * `--import-eq-suggestions` and `--translate-hidden` change nothing on disk and nothing in the + * command's own output - the effect is only visible in what the API stored - so each is asserted by + * reading the translation back. + * + * Not attempted: the no-manager-access guard, which needs a project the token can read but not + * manage. + */ +const LANGUAGE = 'uk'; + +describe('upload translations', () => { + let ctx: SuiteContext; + let stringsBasedProjectId: number; + let sharedStringId: number; + let secretStringId: number; + + beforeAll(async () => { + ctx = await setupSuite('upload-translations', { targetLanguageIds: [LANGUAGE] }); + stringsBasedProjectId = await createExtraProject(ctx, { suite: 'upload-translations-strings', stringsBased: true }); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + async function findStringId(identifier: string): Promise<number> { + const response = await ctx.client.sourceStringsApi.withFetchAll().listProjectStrings(ctx.project.id); + const match = response.data.find((entry) => entry.data.identifier === identifier); + + if (!match) { + throw new Error(`Source string '${identifier}' not found via the API`); + } + + return match.data.id; + } + + test('uploads the sources the rest of the suite translates', async () => { + const result = await ctx.runner.run(['upload', 'sources']); + + expect(result).toMatchObject({ exitCode: 0 }); + + sharedStringId = await findStringId('shared'); + secretStringId = await findStringId('secret'); + + // Hidden from translators, so `--translate-hidden` has something to decide about below. + await ctx.client.sourceStringsApi.editString(ctx.project.id, secretStringId, [ + { op: 'replace', path: '/isHidden', value: true }, + ]); + }); + + test('imports neither an identical string nor a hidden one by default', async () => { + const result = await ctx.runner.run(['upload', 'translations']); + + expect(result).toMatchObject({ exitCode: 0 }); + + // `shared` carries the same text in source and translation; `secret` is hidden. + expect(await translationCount(ctx, sharedStringId, LANGUAGE)).toBe(0); + expect(await translationCount(ctx, secretStringId, LANGUAGE)).toBe(0); + }); + + test('imports a translation equal to the source with --import-eq-suggestions', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--import-eq-suggestions']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(await translationCount(ctx, sharedStringId, LANGUAGE)).toBeGreaterThan(0); + // Still untouched: this flag decides about identical text, not about hidden strings, so the + // next test cannot pass on the back of this run. + expect(await translationCount(ctx, secretStringId, LANGUAGE)).toBe(0); + }); + + test('imports a translation for a hidden string with --translate-hidden', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--translate-hidden']); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(await translationCount(ctx, secretStringId, LANGUAGE)).toBeGreaterThan(0); + }); + + test('rejects a language the project does not target', async () => { + const result = await ctx.runner.run(['upload', 'translations', '-l', 'de']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Language 'de' does not exist in the project"); + }); + + test('requires a branch for a string-based project', async () => { + const result = await ctx.runner.run(['upload', 'translations', '--project-id', String(stringsBasedProjectId)]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('A branch is required to upload translations for a strings-based project'); + }); +}); diff --git a/tests/e2e/suites/without-config-param.test.ts b/tests/e2e/suites/without-config-param.test.ts new file mode 100644 index 000000000..4a21cd30d --- /dev/null +++ b/tests/e2e/suites/without-config-param.test.ts @@ -0,0 +1,143 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { rename } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expectFilesExist } from '../helpers/files.ts'; +import { normalize } from '../helpers/normalize.ts'; +import { type SuiteContext, setupSuite, teardownSuite } from '../helpers/suite.ts'; + +/** + * Upload sources / upload translations / download translations run three ways - discovered via `crowdin.yaml`, discovered + * via `crowdin.yml`, and with no config file at all, credentials and patterns given as CLI flags. + * + * With `--config` omitted the CLI checks `crowdin.yml` then `crowdin.yaml`, in the cwd only. So the + * `crowdin.yaml` phase requires `crowdin.yml` to be absent, so `beforeAll` renames the one + * `setupSuite` writes to `crowdin.yaml`, and the `crowdin.yml` phase renames it back; every call passes `noConfig: true` so the harness never appends its own + * `-c`, leaving this suite in full control of which filename exists. + */ +describe('cli commands without an explicit config parameter', () => { + let ctx: SuiteContext; + + beforeAll(async () => { + ctx = await setupSuite('without-config-param', { targetLanguageIds: ['it', 'uk'] }); + + // Phase 1: only crowdin.yaml present. + await rename(join(ctx.workspace, 'crowdin.yml'), join(ctx.workspace, 'crowdin.yaml')); + }); + + afterAll(async () => { + await teardownSuite(ctx); + }); + + test('uploads sources via crowdin.yaml default discovery (no -c)', async () => { + const result = await ctx.runner.run(['upload', 'sources'], { noConfig: true, cwd: ctx.workspace }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations via crowdin.yaml default discovery (no -c)', async () => { + const result = await ctx.runner.run(['upload', 'translations'], { noConfig: true, cwd: ctx.workspace }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations via crowdin.yaml default discovery (no -c)', async () => { + const result = await ctx.runner.run(['download', 'translations'], { noConfig: true, cwd: ctx.workspace }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'translations/it/android.xml', 'translations/uk/android.xml'); + }); + + test('uploads sources via crowdin.yml default discovery (no -c)', async () => { + // Phase 2: only crowdin.yml present. + await rename(join(ctx.workspace, 'crowdin.yaml'), join(ctx.workspace, 'crowdin.yml')); + + const result = await ctx.runner.run(['upload', 'sources'], { noConfig: true, cwd: ctx.workspace }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('uploads translations via crowdin.yml default discovery (no -c)', async () => { + const result = await ctx.runner.run(['upload', 'translations'], { noConfig: true, cwd: ctx.workspace }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml'"); + expect(result.stdout).toContain("File 'translations/uk/android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations via crowdin.yml default discovery (no -c)', async () => { + const result = await ctx.runner.run(['download', 'translations'], { noConfig: true, cwd: ctx.workspace }); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'translations/it/android.xml', 'translations/uk/android.xml'); + }); + + test('uploads sources using only CLI flags, no config file at all', async () => { + // `-s`/`-t` together skip reading any config file, so the crowdin.yml left + // over from the previous test is ignored. + const result = await ctx.runner.run( + [ + 'upload', + 'sources', + '-s', + 'sources/android.xml', + '-t', + 'translations/%two_letters_code%/%original_file_name%', + '-i', + String(ctx.project.id), + '-T', + ctx.env.token as string, + '--no-progress', + '--no-colors', + ], + { noConfig: true }, + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'android.xml'"); + expect(normalize(result.stdout)).toMatchSnapshot(); + }); + + test('downloads translations using only CLI flags, no config file at all', async () => { + // Same as above, download side. + const result = await ctx.runner.run( + [ + 'download', + 'translations', + '-s', + 'sources/android.xml', + '-t', + 'translations/%two_letters_code%/%original_file_name%', + '-i', + String(ctx.project.id), + '-T', + ctx.env.token as string, + '--no-progress', + '--no-colors', + ], + { noConfig: true }, + ); + + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("File 'translations/it/android.xml' extracted"); + expect(result.stdout).toContain("File 'translations/uk/android.xml' extracted"); + expect(normalize(result.stdout)).toMatchSnapshot(); + + await expectFilesExist(ctx.workspace, 'translations/it/android.xml', 'translations/uk/android.xml'); + }); +}); diff --git a/tests/unit/cli/builder.test.ts b/tests/unit/cli/builder.test.ts new file mode 100644 index 000000000..d673bd7fa --- /dev/null +++ b/tests/unit/cli/builder.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import { Command } from 'commander'; +import { buildOption } from '@/cli/builder.ts'; +import type { OptionDef } from '@/cli/types.ts'; + +const language: OptionDef = { + name: 'language', + short: 'l', + type: 'string', + variadic: true, + required: true, + description: 'Target language identifier', +}; + +describe('buildOption negation', () => { + // The tri-state pattern: a positive flag plus a `--no-` sibling share one attribute, so the field + // stays `undefined` unless one of them is passed and the request omits it entirely. That only + // works because commander strips `no-` to derive the attribute name - if it did not, the sibling + // would land on its own key and silently never reach the request. + test.each([ + ['no-duplicate-translations', 'duplicateTranslations'], + ['no-translate-with-perfect-match-only', 'translateWithPerfectMatchOnly'], + ['no-preserve-hierarchy', 'preserveHierarchy'], + ['no-auto-update', 'autoUpdate'], + ['no-hidden', 'hidden'], + ])('maps --%s onto %s, negated', (name, attribute) => { + const option = buildOption({ name, type: 'boolean', description: 'd' }); + + expect(option.attributeName()).toBe(attribute); + expect(option.negate).toBe(true); + }); + + test('parses the pair into the tri-state the request builders read', () => { + const command = new Command() + .addOption(buildOption({ name: 'duplicate-translations', type: 'boolean', description: 'd' })) + .addOption(buildOption({ name: 'no-duplicate-translations', type: 'boolean', description: 'd' })) + .exitOverride(); + + expect(command.parse([], { from: 'user' }).opts().duplicateTranslations).toBeUndefined(); + expect(command.parse(['--duplicate-translations'], { from: 'user' }).opts().duplicateTranslations).toBe(true); + expect(command.parse(['--no-duplicate-translations'], { from: 'user' }).opts().duplicateTranslations).toBe(false); + }); +}); + +describe('buildOption', () => { + test('marks a required option mandatory', () => { + const command = new Command('add').exitOverride().addOption(buildOption(language)); + + expect(() => command.parse([], { from: 'user' })).toThrow(/required option .* not specified/); + expect(() => command.parse(['-l', 'uk'], { from: 'user' })).not.toThrow(); + }); +}); diff --git a/tests/cli/commands.test.ts b/tests/unit/cli/commands.test.ts similarity index 98% rename from tests/cli/commands.test.ts rename to tests/unit/cli/commands.test.ts index 31a7737c5..36776f4be 100644 --- a/tests/cli/commands.test.ts +++ b/tests/unit/cli/commands.test.ts @@ -4,7 +4,7 @@ import type { CommandDef, OptionDef, OptionGroupDef, SubcommandDef } from '@/cli const CONFIG_GROUP = 'Config options:'; -// Option-name sets per Java picocli param tier (BaseParams -> ProjectParams -> ParamsWithFiles). +// Option-name sets per param tier (base -> project -> with files). const BASE_TIER = ['token', 'base-url', 'base-path']; const PROJECT_TIER = [...BASE_TIER, 'project-id']; // `preserve-hierarchy` is the hidden positive half of the `--no-preserve-hierarchy` pair. diff --git a/tests/cli/commands/app/AppCommand.test.ts b/tests/unit/cli/commands/app/AppCommand.test.ts similarity index 100% rename from tests/cli/commands/app/AppCommand.test.ts rename to tests/unit/cli/commands/app/AppCommand.test.ts diff --git a/tests/cli/commands/auto-translate/AutoTranslateCommand.test.ts b/tests/unit/cli/commands/auto-translate/AutoTranslateCommand.test.ts similarity index 100% rename from tests/cli/commands/auto-translate/AutoTranslateCommand.test.ts rename to tests/unit/cli/commands/auto-translate/AutoTranslateCommand.test.ts diff --git a/tests/cli/commands/branch/BranchCommand.test.ts b/tests/unit/cli/commands/branch/BranchCommand.test.ts similarity index 98% rename from tests/cli/commands/branch/BranchCommand.test.ts rename to tests/unit/cli/commands/branch/BranchCommand.test.ts index 0fd0bc564..1280fa280 100644 --- a/tests/cli/commands/branch/BranchCommand.test.ts +++ b/tests/unit/cli/commands/branch/BranchCommand.test.ts @@ -82,7 +82,7 @@ describe('BranchCommand', () => { spyOn(Bun, 'sleep').mockResolvedValue(undefined); spyOn(console, 'log').mockImplementation(() => {}); - spyOn(console, 'error').mockImplementation(() => {}); + spyOn(process.stderr, 'write').mockImplementation(() => true); spyOn(console, 'table').mockImplementation(() => {}); }); @@ -266,7 +266,7 @@ describe('BranchCommand', () => { await branchCommand.addAction(createCommandContext({}, ['main'])); expect(branchService.add).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( + expect(process.stderr.write).toHaveBeenCalledWith( expect.stringContaining("Branch 'main' already exists in the project"), ); }); @@ -319,7 +319,9 @@ describe('BranchCommand', () => { await branchCommand.deleteAction(createCommandContext({}, ['main'])); expect(branchService.delete).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Branch 'main' doesn't exist in the project")); + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining("Branch 'main' doesn't exist in the project"), + ); }); test('propagates delete errors', async () => { @@ -572,7 +574,6 @@ describe('BranchCommand', () => { ); }); - // Java BranchMergeAction prints the target branch id alone in plain view. test('prints the target branch id in plain format', async () => { const branchCommand = createBranchCommand(); output = createOutput({ ...globalOptions, output: 'plain' }); diff --git a/tests/cli/commands/branch/views.test.ts b/tests/unit/cli/commands/branch/views.test.ts similarity index 95% rename from tests/cli/commands/branch/views.test.ts rename to tests/unit/cli/commands/branch/views.test.ts index 90c7302bc..21ffd3bbe 100644 --- a/tests/cli/commands/branch/views.test.ts +++ b/tests/unit/cli/commands/branch/views.test.ts @@ -32,7 +32,6 @@ describe('branch views', () => { expect(mergeView('dev', 'main').text(summary)).not.toContain('targetBranchId'); }); - // Java BranchMergeAction prints the target branch id alone in plain view. test('prints the target branch id alone in plain', () => { expect(mergeView('dev', 'main').plain?.(summary)).toBe('15'); }); diff --git a/tests/cli/commands/bundle/BundleCommand.test.ts b/tests/unit/cli/commands/bundle/BundleCommand.test.ts similarity index 100% rename from tests/cli/commands/bundle/BundleCommand.test.ts rename to tests/unit/cli/commands/bundle/BundleCommand.test.ts diff --git a/tests/cli/commands/comment/CommentCommand.test.ts b/tests/unit/cli/commands/comment/CommentCommand.test.ts similarity index 100% rename from tests/cli/commands/comment/CommentCommand.test.ts rename to tests/unit/cli/commands/comment/CommentCommand.test.ts diff --git a/tests/cli/commands/comment/views.test.ts b/tests/unit/cli/commands/comment/views.test.ts similarity index 100% rename from tests/cli/commands/comment/views.test.ts rename to tests/unit/cli/commands/comment/views.test.ts diff --git a/tests/cli/commands/config/ConfigCommand.test.ts b/tests/unit/cli/commands/config/ConfigCommand.test.ts similarity index 99% rename from tests/cli/commands/config/ConfigCommand.test.ts rename to tests/unit/cli/commands/config/ConfigCommand.test.ts index 7f3b999fe..b4fc1524c 100644 --- a/tests/cli/commands/config/ConfigCommand.test.ts +++ b/tests/unit/cli/commands/config/ConfigCommand.test.ts @@ -305,7 +305,7 @@ describe('ConfigCommand translations', () => { ); }); - // Java DryrunTranslations resolves each group's own sources against that group's `translation`. + // Each group's own sources resolve against that group's `translation`. test('lists a path per group when two groups match the same file', async () => { const { list } = await run(managerProject, {}, [ { source: '/**/*.json', translation: '/l/%two_letters_code%/%original_file_name%' }, diff --git a/tests/cli/commands/context/ContextCommand.test.ts b/tests/unit/cli/commands/context/ContextCommand.test.ts similarity index 99% rename from tests/cli/commands/context/ContextCommand.test.ts rename to tests/unit/cli/commands/context/ContextCommand.test.ts index 1ea0632ad..3ae3d8683 100644 --- a/tests/cli/commands/context/ContextCommand.test.ts +++ b/tests/unit/cli/commands/context/ContextCommand.test.ts @@ -84,7 +84,7 @@ describe('ContextCommand', () => { mockProject(); spyOn(console, 'log').mockImplementation(() => {}); - spyOn(console, 'error').mockImplementation(() => {}); + spyOn(process.stderr, 'write').mockImplementation(() => true); spyOn(Bun.inspect, 'table').mockImplementation(() => ''); }); @@ -95,7 +95,7 @@ describe('ContextCommand', () => { // Diagnostics go to stderr, results to stdout; this is everything the user saw. const loggedOutput = () => - [console.log, console.error] + [console.log, process.stderr.write] .flatMap((fn) => (fn as ReturnType<typeof mock>).mock.calls) .map((call) => String(call[0])) .join('\n'); diff --git a/tests/cli/commands/context/views.test.ts b/tests/unit/cli/commands/context/views.test.ts similarity index 100% rename from tests/cli/commands/context/views.test.ts rename to tests/unit/cli/commands/context/views.test.ts diff --git a/tests/cli/commands/distribution/DistributionCommand.test.ts b/tests/unit/cli/commands/distribution/DistributionCommand.test.ts similarity index 98% rename from tests/cli/commands/distribution/DistributionCommand.test.ts rename to tests/unit/cli/commands/distribution/DistributionCommand.test.ts index 65b2bd492..0eafd465b 100644 --- a/tests/cli/commands/distribution/DistributionCommand.test.ts +++ b/tests/unit/cli/commands/distribution/DistributionCommand.test.ts @@ -130,8 +130,8 @@ describe('DistributionCommand', () => { expect(console.log).toHaveBeenCalledWith('hash-1 CDN one'); }); - // Java's add/edit echoes print the name alone in plain; we keep the listing's shape so the hash - // that `edit`/`release` take stays in the output. + // The add/edit echoes keep the listing's shape in plain so the hash that `edit`/`release` take + // stays in the output. test('echoes hash and name in plain format after add', async () => { output = createOutput({ ...globalOptions, output: 'plain' }); const distributionCommand = createDistributionCommand(); diff --git a/tests/cli/commands/download/DownloadCommand.test.ts b/tests/unit/cli/commands/download/DownloadCommand.test.ts similarity index 99% rename from tests/cli/commands/download/DownloadCommand.test.ts rename to tests/unit/cli/commands/download/DownloadCommand.test.ts index 7f1912e5a..67f9b8f17 100644 --- a/tests/cli/commands/download/DownloadCommand.test.ts +++ b/tests/unit/cli/commands/download/DownloadCommand.test.ts @@ -510,7 +510,7 @@ describe('DownloadCommand', () => { }, } as never); const buildProject = spyOn(apiClient.translationsApi, 'buildProject').mockResolvedValue({} as never); - // Dry-run now always loads the server file map to filter excluded target languages (Java parity). + // Dry-run now always loads the server file map to filter excluded target languages. spyOn(apiClient.sourceFilesApi, 'listProjectFiles').mockResolvedValue({ data: [] } as never); const listSpy = spyOn(output, 'list'); await Bun.write(join(tempDir, 'resources/en/messages.json'), '{}'); @@ -1787,7 +1787,7 @@ describe('DownloadCommand', () => { await downloadCommand.sourcesAction(commandContext); // Else branch: the filename segment is substituted into the source pattern, so the file lands - // at its source-side location (mirrors Java's replaceUnaryAsterisk). + // at its source-side location. expect(await Bun.file(join(tempDir, 'resources', 'en', 'messages.json')).text()).toBe('source content'); }); @@ -1836,7 +1836,7 @@ describe('DownloadCommand', () => { const downloadCommand = createDownloadCommand(); spyOn(projectService, 'isEnterprise').mockReturnValue(false); - // The enterprise guard runs before the project is fetched (Java ordering), so loadProject and + // The enterprise guard runs before the project is fetched, so loadProject and // the build are never reached. const loadProjectSpy = spyOn(projectService, 'loadProject'); const buildReviewedSpy = spyOn(fileService, 'buildReviewedSources'); diff --git a/tests/cli/commands/file/FileCommand.test.ts b/tests/unit/cli/commands/file/FileCommand.test.ts similarity index 99% rename from tests/cli/commands/file/FileCommand.test.ts rename to tests/unit/cli/commands/file/FileCommand.test.ts index dd4d60027..eab43ef89 100644 --- a/tests/cli/commands/file/FileCommand.test.ts +++ b/tests/unit/cli/commands/file/FileCommand.test.ts @@ -216,8 +216,7 @@ describe('FileCommand', () => { }); // loadProjectFiles is scoped to the branch (see FileService), so without '--branch' the listing - // only ever sees the root tree — a project whose files all sit in a branch lists nothing, as - // Java's FileListAction does. + // only ever sees the root tree — a project whose files all sit in a branch lists nothing. test('lists the root tree when no branch is given', async () => { const textOutput = createOutput({ ...globalOptions, output: 'text' }); const fileCommand = createFileCommandWith(textOutput); @@ -644,7 +643,7 @@ describe('FileCommand', () => { }); // The service polls to completion; the command's job is the init line plus a progress line per - // poll. Java prints these percent lines regardless of --verbose, appending the identifier only + // poll. These percent lines print regardless of --verbose, appending the identifier only // under --verbose (unlike `upload translations`, which gates the whole line behind --verbose). test('prints the init line and a progress line per poll when uploading a translation', async () => { const fileCommand = createFileCommand(); diff --git a/tests/cli/commands/file/views.test.ts b/tests/unit/cli/commands/file/views.test.ts similarity index 93% rename from tests/cli/commands/file/views.test.ts rename to tests/unit/cli/commands/file/views.test.ts index 6cb4f1c5c..9a0815010 100644 --- a/tests/cli/commands/file/views.test.ts +++ b/tests/unit/cli/commands/file/views.test.ts @@ -19,7 +19,6 @@ describe('file views', () => { }); test('falls back to the type alone when parser and revision are missing', () => { - // Java's FileListAction switches on FileInfo vs File for the same reason. expect(fileVerboseView.text(createFile())).toBe('#1 docs/readme.md md'); expect(fileVerboseView.plain?.(createFile())).toBe('1 docs/readme.md md'); }); diff --git a/tests/cli/commands/glossary/GlossaryCommand.test.ts b/tests/unit/cli/commands/glossary/GlossaryCommand.test.ts similarity index 99% rename from tests/cli/commands/glossary/GlossaryCommand.test.ts rename to tests/unit/cli/commands/glossary/GlossaryCommand.test.ts index fb0f2628c..bc94b1f26 100644 --- a/tests/cli/commands/glossary/GlossaryCommand.test.ts +++ b/tests/unit/cli/commands/glossary/GlossaryCommand.test.ts @@ -73,7 +73,7 @@ describe('GlossaryCommand', () => { }; spyOn(console, 'log').mockImplementation(() => {}); - spyOn(console, 'error').mockImplementation(() => {}); + spyOn(process.stderr, 'write').mockImplementation(() => true); spyOn(console, 'table').mockImplementation(() => {}); }); @@ -178,7 +178,7 @@ describe('GlossaryCommand', () => { await glossaryCommand.listAction(createCommandContext({ verbose: true, output: 'text' })); - expect(console.error).toHaveBeenCalledWith( + expect(process.stderr.write).toHaveBeenCalledWith( expect.stringContaining('You do not have permission to manage this glossary'), ); }); diff --git a/tests/cli/commands/glossary/views.test.ts b/tests/unit/cli/commands/glossary/views.test.ts similarity index 100% rename from tests/cli/commands/glossary/views.test.ts rename to tests/unit/cli/commands/glossary/views.test.ts diff --git a/tests/cli/commands/init/InitCommand.test.ts b/tests/unit/cli/commands/init/InitCommand.test.ts similarity index 99% rename from tests/cli/commands/init/InitCommand.test.ts rename to tests/unit/cli/commands/init/InitCommand.test.ts index e814c8079..7336d872a 100644 --- a/tests/cli/commands/init/InitCommand.test.ts +++ b/tests/unit/cli/commands/init/InitCommand.test.ts @@ -305,7 +305,7 @@ describe('InitCommand', () => { ); // @ts-expect-error expect(command.validateTranslationPattern('/resources/%two_letters_code%/%original_file_name%')).toBeUndefined(); - // @ts-expect-error - empty is allowed (matches Java) + // @ts-expect-error - empty is allowed expect(command.validateTranslationPattern('')).toBeUndefined(); }); diff --git a/tests/cli/commands/label/LabelCommand.test.ts b/tests/unit/cli/commands/label/LabelCommand.test.ts similarity index 96% rename from tests/cli/commands/label/LabelCommand.test.ts rename to tests/unit/cli/commands/label/LabelCommand.test.ts index a13a87037..2aa5d9b49 100644 --- a/tests/cli/commands/label/LabelCommand.test.ts +++ b/tests/unit/cli/commands/label/LabelCommand.test.ts @@ -48,7 +48,7 @@ describe('LabelCommand', () => { }; spyOn(console, 'log').mockImplementation(() => {}); - spyOn(console, 'error').mockImplementation(() => {}); + spyOn(process.stderr, 'write').mockImplementation(() => true); spyOn(console, 'table').mockImplementation(() => {}); }); @@ -130,7 +130,7 @@ describe('LabelCommand', () => { expect(console.log).toHaveBeenCalledWith('one'); }); - // Java LabelListAction prints the decorated line when `!plainView || isVerbose`. + // The decorated line is printed unless the output is plain and not verbose. test('keeps the id in plain format when verbose', async () => { output = createOutput({ ...globalOptions, output: 'plain' }); const labelCommand = createLabelCommand(); @@ -170,7 +170,9 @@ describe('LabelCommand', () => { await labelCommand.addAction(createCommandContext(globalOptions, ['main'])); expect(labelService.add).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Label 'main' already exists in the project")); + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining("Label 'main' already exists in the project"), + ); }); test('requires title for add action', async () => { diff --git a/tests/cli/commands/language/LanguageCommand.test.ts b/tests/unit/cli/commands/language/LanguageCommand.test.ts similarity index 100% rename from tests/cli/commands/language/LanguageCommand.test.ts rename to tests/unit/cli/commands/language/LanguageCommand.test.ts diff --git a/tests/cli/commands/login/LoginCommand.test.ts b/tests/unit/cli/commands/login/LoginCommand.test.ts similarity index 100% rename from tests/cli/commands/login/LoginCommand.test.ts rename to tests/unit/cli/commands/login/LoginCommand.test.ts diff --git a/tests/cli/commands/project/ProjectCommand.test.ts b/tests/unit/cli/commands/project/ProjectCommand.test.ts similarity index 96% rename from tests/cli/commands/project/ProjectCommand.test.ts rename to tests/unit/cli/commands/project/ProjectCommand.test.ts index c7462736c..e1c506ae9 100644 --- a/tests/cli/commands/project/ProjectCommand.test.ts +++ b/tests/unit/cli/commands/project/ProjectCommand.test.ts @@ -105,7 +105,7 @@ describe('ProjectCommand', () => { spyOn(projectService, 'loadProject').mockResolvedValue({ data: { id: 123, webUrl: 'https://crowdin.com/project/demo' }, } as never); - const error = spyOn(console, 'error').mockImplementation(() => {}); + const error = spyOn(process.stderr, 'write').mockImplementation(() => true); await projectCommand.browseAction(commandContext); @@ -241,9 +241,8 @@ describe('ProjectCommand', () => { }); }); - // Java ProjectAddAction prints the id alone under --plain, unlike the listing it shares its text - // line with. - test('echoes the bare id in plain, as Java ProjectAddAction does', async () => { + // Prints the id alone, unlike the listing it shares its text line with. + test('echoes the bare id in plain', async () => { const plainOutput = createOutput({ ...globalOptions, output: 'plain' }); const projectCommand = new ProjectCommand( () => plainOutput, diff --git a/tests/cli/commands/project/views.test.ts b/tests/unit/cli/commands/project/views.test.ts similarity index 87% rename from tests/cli/commands/project/views.test.ts rename to tests/unit/cli/commands/project/views.test.ts index edd8086e0..9588cce3d 100644 --- a/tests/cli/commands/project/views.test.ts +++ b/tests/unit/cli/commands/project/views.test.ts @@ -20,7 +20,7 @@ describe('project views', () => { expect(projectVerboseView.text(project)).toBe('#1 Docs string-based open 2025-01-01T10:00:00.000Z'); }); - test('defaults to file-based and private, as Java does for enterprise responses', () => { + test('defaults to file-based and private for enterprise responses', () => { expect(projectVerboseView.text(createProject())).toBe('#1 Docs file-based private '); }); @@ -36,12 +36,12 @@ describe('project views', () => { ); }); - test('has no plain override, since Java ProjectListAction has no plain branch', () => { + test('has no plain override', () => { expect(projectView.plain).toBeUndefined(); expect(projectVerboseView.plain).toBeUndefined(); }); - // ProjectAddAction does have one, so the add echo carries the id a script needs. + // The add view does have one, so the add echo carries the id a script needs. test('prints the id alone in the add view, sharing the listing text line', () => { const project = createProject(); diff --git a/tests/cli/commands/screenshot/ScreenshotCommand.test.ts b/tests/unit/cli/commands/screenshot/ScreenshotCommand.test.ts similarity index 100% rename from tests/cli/commands/screenshot/ScreenshotCommand.test.ts rename to tests/unit/cli/commands/screenshot/ScreenshotCommand.test.ts diff --git a/tests/cli/commands/status/StatusCommand.test.ts b/tests/unit/cli/commands/status/StatusCommand.test.ts similarity index 98% rename from tests/cli/commands/status/StatusCommand.test.ts rename to tests/unit/cli/commands/status/StatusCommand.test.ts index 423dd9e92..0776edf7d 100644 --- a/tests/cli/commands/status/StatusCommand.test.ts +++ b/tests/unit/cli/commands/status/StatusCommand.test.ts @@ -159,7 +159,7 @@ describe('StatusCommand', () => { ); }); - // Java throws at the end of its non-verbose branch, so the progress is on screen before it fails. + // The progress is on screen before it fails. test('prints the progress before failing with --fail-if-incomplete', async () => { const statusCommand = createStatusCommand(); @@ -175,7 +175,7 @@ describe('StatusCommand', () => { expect(console.log).toHaveBeenCalledWith(JSON.stringify([{ language: 'fr', translation: 99 }], null, 2)); }); - // Java StatusAction verbose view: word and phrase counts per language, rendered as a wider grid. + // Verbose view: word and phrase counts per language, rendered as a wider grid. test('renders per-language detail with --verbose', async () => { output = createOutput({ ...globalOptions, output: 'text' }); const statusCommand = createStatusCommand(); diff --git a/tests/cli/commands/status/views.test.ts b/tests/unit/cli/commands/status/views.test.ts similarity index 100% rename from tests/cli/commands/status/views.test.ts rename to tests/unit/cli/commands/status/views.test.ts diff --git a/tests/cli/commands/string/StringCommand.test.ts b/tests/unit/cli/commands/string/StringCommand.test.ts similarity index 99% rename from tests/cli/commands/string/StringCommand.test.ts rename to tests/unit/cli/commands/string/StringCommand.test.ts index 4d4b966aa..7c8e9ff76 100644 --- a/tests/cli/commands/string/StringCommand.test.ts +++ b/tests/unit/cli/commands/string/StringCommand.test.ts @@ -283,7 +283,7 @@ describe('StringCommand', () => { expect(fileService.listProjectFilePaths).not.toHaveBeenCalled(); }); - // Java keeps the verbose detail lines outside its plainView branch, unlike glossary's terms. + // Unlike glossary's terms, the verbose detail lines are not dropped in plain. test('still renders the verbose detail lines in plain format', async () => { const plainOutput = createOutput({ ...globalOptions, output: 'plain' }); const cmd = createStringCommandWith(plainOutput); @@ -492,7 +492,6 @@ describe('StringCommand', () => { expect(fileService.listProjectFilePaths).not.toHaveBeenCalled(); }); - // Java StringEditAction passes isVerbose to printSourceString, so the echo carries the details. test('echoes the verbose detail lines with --verbose', async () => { const textOutput = createOutput({ ...globalOptions, output: 'text' }); const cmd = createStringCommandWith(textOutput); diff --git a/tests/cli/commands/string/views.test.ts b/tests/unit/cli/commands/string/views.test.ts similarity index 98% rename from tests/cli/commands/string/views.test.ts rename to tests/unit/cli/commands/string/views.test.ts index c62ba1dbf..c66b7c0e0 100644 --- a/tests/cli/commands/string/views.test.ts +++ b/tests/unit/cli/commands/string/views.test.ts @@ -19,7 +19,6 @@ describe('string views', () => { }); test('drops the identifier when the string has none', () => { - // Java falls back to message.source_string_list_text_short. expect(createStringView().text(createString({ identifier: undefined }))).toBe('#11 Hello'); }); diff --git a/tests/cli/commands/task/TaskCommand.test.ts b/tests/unit/cli/commands/task/TaskCommand.test.ts similarity index 100% rename from tests/cli/commands/task/TaskCommand.test.ts rename to tests/unit/cli/commands/task/TaskCommand.test.ts diff --git a/tests/cli/commands/task/views.test.ts b/tests/unit/cli/commands/task/views.test.ts similarity index 94% rename from tests/cli/commands/task/views.test.ts rename to tests/unit/cli/commands/task/views.test.ts index 8a6592054..3666143e4 100644 --- a/tests/cli/commands/task/views.test.ts +++ b/tests/unit/cli/commands/task/views.test.ts @@ -23,7 +23,7 @@ describe('task views', () => { expect(taskVerboseView.text(task)).toBe('#11 fr First task todo 42 NoDueDate'); }); - test('ignores verbose in plain, as Java TaskListAction does', () => { + test('ignores verbose in plain', () => { const task = createTask({ status: 'todo' as TasksModel.Status, wordsCount: 42 }); expect(taskVerboseView.plain?.(task)).toBe('11 First task'); diff --git a/tests/cli/commands/tm/TmCommand.test.ts b/tests/unit/cli/commands/tm/TmCommand.test.ts similarity index 100% rename from tests/cli/commands/tm/TmCommand.test.ts rename to tests/unit/cli/commands/tm/TmCommand.test.ts diff --git a/tests/cli/commands/upload/UploadSourcesCommand.test.ts b/tests/unit/cli/commands/upload/UploadSourcesCommand.test.ts similarity index 99% rename from tests/cli/commands/upload/UploadSourcesCommand.test.ts rename to tests/unit/cli/commands/upload/UploadSourcesCommand.test.ts index 14b093a17..50232e94a 100644 --- a/tests/cli/commands/upload/UploadSourcesCommand.test.ts +++ b/tests/unit/cli/commands/upload/UploadSourcesCommand.test.ts @@ -1832,9 +1832,8 @@ describe('UploadSourcesCommand', () => { expect(createCalls[0]?.[0]?.name).toBe('app.json'); }); - // Java suppresses the message under --plain and returns, exiting 0 on a pattern that matched - // nothing. `upload translations` keeps the exit code and drops only the message; a plain - // consumer is a script, so that is the behaviour worth carrying here too. + // A plain consumer is a script, so the exit code is kept and only the message is dropped, + // same as `upload translations`. test('keeps the exit code in plain, message suppressed, when a pattern matches nothing', async () => { const output = createOutputMock(); const command = createUploadCommand( @@ -1992,8 +1991,8 @@ describe('UploadSourcesCommand', () => { expect(summaryOf(output)).toEqual([{ path: 'src/app.json', action: 'skipped', reason: 'auto-update disabled' }]); }); - // plain is line-oriented and cannot carry the action, so it lists only what changed — Java - // prints nothing there for a skipped file. + // plain is line-oriented and cannot carry the action, so it lists only what changed and + // prints nothing for a skipped file. test('lists only changed files in plain', async () => { await Bun.write(`${tempDir}/src/uploaded.json`, '{}'); await Bun.write(`${tempDir}/src/kept.json`, '{}'); diff --git a/tests/cli/commands/upload/UploadTranslationsCommand.test.ts b/tests/unit/cli/commands/upload/UploadTranslationsCommand.test.ts similarity index 99% rename from tests/cli/commands/upload/UploadTranslationsCommand.test.ts rename to tests/unit/cli/commands/upload/UploadTranslationsCommand.test.ts index 25ad144f0..97239360c 100644 --- a/tests/cli/commands/upload/UploadTranslationsCommand.test.ts +++ b/tests/unit/cli/commands/upload/UploadTranslationsCommand.test.ts @@ -591,8 +591,7 @@ describe('UploadTranslationsCommand', () => { expect(output.info).toHaveBeenCalledWith("File 'locale/es/app.json' would be queued for translations import"); }); - // Java routes --dryrun to ListTranslationsAction -> DryrunTranslations, which resolves paths from - // local sources only and never looks the source up in the project. + // Dry-run resolves paths from local sources only and never looks the source up in the project. test('dry-run does not check whether the source exists in the project', async () => { await Bun.write(`${tempDir}/src/app.json`, '{}'); await Bun.write(`${tempDir}/locale/es/app.json`, '{}'); @@ -628,7 +627,7 @@ describe('UploadTranslationsCommand', () => { test('dry-run lists translation paths that do not exist on disk yet', async () => { await Bun.write(`${tempDir}/src/app.json`, '{}'); - // Deliberately no locale/es/app.json: Java passes filesMustExist=false, so it is still listed. + // Deliberately no locale/es/app.json: files need not exist, so it is still listed. const projectService = { loadProject: mock(async () => ({ diff --git a/tests/cli/commands/upload/uploadTestHelpers.ts b/tests/unit/cli/commands/upload/uploadTestHelpers.ts similarity index 100% rename from tests/cli/commands/upload/uploadTestHelpers.ts rename to tests/unit/cli/commands/upload/uploadTestHelpers.ts diff --git a/tests/cli/completion.integration.test.ts b/tests/unit/cli/completion.integration.test.ts similarity index 97% rename from tests/cli/completion.integration.test.ts rename to tests/unit/cli/completion.integration.test.ts index f005d588b..c9fb1a7c9 100644 --- a/tests/cli/completion.integration.test.ts +++ b/tests/unit/cli/completion.integration.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; // Pure argv -> stdout, no network. Spawning (rather than importing) is deliberate: it preserves the // trailing empty arg the shell sends to request "complete the next token". -const CLI = join(import.meta.dir, '..', '..', 'src-next', 'cli.ts'); +const CLI = join(import.meta.dir, '..', '..', '..', 'src-next', 'cli.ts'); async function complete(args: string[]): Promise<string> { const proc = Bun.spawn(['bun', CLI, 'complete', ...args], { diff --git a/tests/cli/config.test.ts b/tests/unit/cli/config.test.ts similarity index 97% rename from tests/cli/config.test.ts rename to tests/unit/cli/config.test.ts index 1248ff216..8508fd1ac 100644 --- a/tests/cli/config.test.ts +++ b/tests/unit/cli/config.test.ts @@ -61,7 +61,7 @@ describe('createGetConfig', () => { output = createOutput(globalOptions); spyOn(console, 'log').mockImplementation(() => {}); - spyOn(console, 'error').mockImplementation(() => {}); + spyOn(process.stderr, 'write').mockImplementation(() => true); }); afterEach(async () => { @@ -165,8 +165,8 @@ describe('createGetConfig', () => { }); test('an explicit --identity suppresses the default ~/.crowdin.yml (single identity slot)', async () => { - // Home file has a token; --identity file does not. Config has no token. Java uses only the - // explicit identity file, so the home token is never picked up. + // Home file has a token; --identity file does not. Config has no token. Only the + // explicit identity file is read, so the home token is never picked up. await Bun.write(configPath, CONFIG_YAML.replace(`api_token: "${TOKEN}"\n`, '')); await Bun.write(join(homeDir, '.crowdin.yml'), `api_token: "${HOME_TOKEN}"\n`); const identityPath = join(tempDir, 'identity.yml'); @@ -227,7 +227,7 @@ describe('createGetConfig', () => { { source: '/only/*.md', translation: '/tr/%two_letters_code%/%original_file_name%', - // A patterned --source needs a per-file dest, same as in a config file (FileBean.checkDest). + // A patterned --source needs a per-file dest, same as in a config file. dest: '/dest/%original_file_name%', }, ['source'], @@ -258,7 +258,7 @@ describe('createGetConfig', () => { test('resolves credentials from CROWDIN_* env vars when the config file omits them', async () => { // Config file with no project_id / api_token: the whole credential set comes from the - // environment. Proves validation runs after env resolution, not before (Java parity). + // environment. Proves validation runs after env resolution, not before. await Bun.write( configPath, ['files:', ' - source: /src/**/*.json', ' translation: /l10n/%locale%/%original_file_name%', ''].join('\n'), @@ -314,7 +314,7 @@ describe('createGetConfig', () => { } }); - test('a `*_env` key wins over a literal key set in the same file (Java parity)', async () => { + test('a `*_env` key wins over a literal key set in the same file', async () => { await Bun.write( configPath, [ @@ -385,8 +385,8 @@ describe('createGetConfig', () => { expect(promise).rejects.toThrow(/should be a directory/); }); - // Java collects every config problem into one ValidationException instead of failing on the first, - // so a single run tells you everything to fix (BaseProperties/ProjectProperties.checkProperties). + // Every config problem is collected into one error instead of failing on the first, + // so a single run tells you everything to fix. test('reports a bad base_path and a missing project_id together', async () => { await Bun.write(configPath, `api_token: "${TOKEN}"\nbase_path: ./nope\n`); @@ -398,7 +398,7 @@ describe('createGetConfig', () => { }); // The project tier is the command's declared option set: no --project-id, no requirement - // (Java runs glossary/tm on BaseProperties, which has no project_id at all). + // (glossary/tm have no project_id at all). test('does not require project_id for a command that does not declare it', async () => { await Bun.write(configPath, `api_token: "${TOKEN}"\n`); diff --git a/tests/cli/errors/CliError.test.ts b/tests/unit/cli/errors/CliError.test.ts similarity index 100% rename from tests/cli/errors/CliError.test.ts rename to tests/unit/cli/errors/CliError.test.ts diff --git a/tests/cli/errors/toCliError.test.ts b/tests/unit/cli/errors/toCliError.test.ts similarity index 96% rename from tests/cli/errors/toCliError.test.ts rename to tests/unit/cli/errors/toCliError.test.ts index 48739f665..ba97d852b 100644 --- a/tests/cli/errors/toCliError.test.ts +++ b/tests/unit/cli/errors/toCliError.test.ts @@ -3,8 +3,8 @@ import { CrowdinError, CrowdinValidationError } from '@crowdin/crowdin-api-clien import { ExitCode } from '@/cli/errors/CliError.ts'; import { toCliError } from '@/cli/errors/toCliError.ts'; -// the same 401 must yield the same exit code and message from every command. Java -// mapped HTTP status → exit code centrally (CrowdinClientCore); a service hardcoding exit 1 broke parity. +// the same 401 must yield the same exit code and message from every command, so HTTP status maps +// to exit code centrally; a service hardcoding exit 1 would break that. describe('toCliError HTTP status mapping', () => { test.each([ [401, ExitCode.AUTHORIZATION, "Couldn't authorize. Check your 'api_token'"], diff --git a/tests/cli/exitCode.integration.test.ts b/tests/unit/cli/exitCode.integration.test.ts similarity index 93% rename from tests/cli/exitCode.integration.test.ts rename to tests/unit/cli/exitCode.integration.test.ts index a54a30c53..44aeaec89 100644 --- a/tests/cli/exitCode.integration.test.ts +++ b/tests/unit/cli/exitCode.integration.test.ts @@ -7,7 +7,7 @@ import { join } from 'node:path'; // code for paths that need no network (usage errors and config load failures). The HTTP-error // codes (101/103/129) are covered at the unit level in cli/errors/CliError.test.ts. -const CLI = join(import.meta.dir, '..', '..', 'src-next', 'cli.ts'); +const CLI = join(import.meta.dir, '..', '..', '..', 'src-next', 'cli.ts'); let workspace: string; @@ -80,7 +80,7 @@ describe('exit codes (offline, end-to-end)', () => { }); // an unknown command/subcommand must read as "unknown command", not commander's - // confusing "too many arguments" (Java picocli parity: "Unknown subcommand 'X'"). + // confusing "too many arguments". test('unknown root command reports "unknown command", not "too many arguments"', async () => { const out = await captureCli(['definitely-not-a-command'], workspace); expect(out).toContain("unknown command 'definitely-not-a-command'"); @@ -121,8 +121,7 @@ describe('exit codes (offline, end-to-end)', () => { expect(await runCli(['file', '--bogus'], workspace)).toBe(2); }); - // Only the files tier insists on a config file, and only when no --source/--translation replaces - // it (Java PropertiesBuilders.buildPropertiesWithFiles + ParamsWithFiles.isEmpty). + // Only the files tier insists on a config file, and only when no --source/--translation replaces it. test('missing config file exits 102 (not found) for a file-based command', async () => { const dir = await mkdtemp(join(tmpdir(), 'crowdin-exitcode-missing-')); @@ -143,8 +142,7 @@ describe('exit codes (offline, end-to-end)', () => { } }); - // Java separates "doesn't exist" (102) from "that's a folder" (2) for both file options - // (ConfigurationFilesProperties.getConfigFile / getIdentityFile). + // "Doesn't exist" (102) and "that's a folder" (2) are separate errors for both file options. test('explicit --config pointing at a directory exits 2 (validation)', async () => { const dir = await mkdtemp(join(tmpdir(), 'crowdin-exitcode-configdir-')); await mkdir(join(dir, 'somedir')); @@ -168,8 +166,7 @@ describe('exit codes (offline, end-to-end)', () => { }); // A project-scoped command reads the config only when it happens to exist. With no config file and - // no token, Java reports the missing file rather than the missing options - // (BaseProperties.checkProperties -> NotFoundException, exit 102). + // no token, the missing file is reported rather than the missing options. test('project-scoped command without a config file exits 102 (not found)', async () => { const dir = await mkdtemp(join(tmpdir(), 'crowdin-exitcode-noconfig-')); diff --git a/tests/cli/services/AppService.test.ts b/tests/unit/cli/services/AppService.test.ts similarity index 100% rename from tests/cli/services/AppService.test.ts rename to tests/unit/cli/services/AppService.test.ts diff --git a/tests/cli/services/BranchService.test.ts b/tests/unit/cli/services/BranchService.test.ts similarity index 100% rename from tests/cli/services/BranchService.test.ts rename to tests/unit/cli/services/BranchService.test.ts diff --git a/tests/cli/services/BundleService.test.ts b/tests/unit/cli/services/BundleService.test.ts similarity index 97% rename from tests/cli/services/BundleService.test.ts rename to tests/unit/cli/services/BundleService.test.ts index 46ab6acd2..9a9cded5c 100644 --- a/tests/cli/services/BundleService.test.ts +++ b/tests/unit/cli/services/BundleService.test.ts @@ -53,7 +53,6 @@ describe('BundleService', () => { expect(bundleService.exportBundle(5)).rejects.toThrow(new CliError('Failed to build the bundle')); }); - // Java retries the export start on a transient "another export in progress" error. test('retries the start on a transient in-progress error', async () => { const start = spyOn(apiClient.bundlesApi, 'exportBundle') .mockRejectedValueOnce( diff --git a/tests/cli/services/DirectoryService.test.ts b/tests/unit/cli/services/DirectoryService.test.ts similarity index 100% rename from tests/cli/services/DirectoryService.test.ts rename to tests/unit/cli/services/DirectoryService.test.ts diff --git a/tests/cli/services/DistributionService.test.ts b/tests/unit/cli/services/DistributionService.test.ts similarity index 100% rename from tests/cli/services/DistributionService.test.ts rename to tests/unit/cli/services/DistributionService.test.ts diff --git a/tests/cli/services/FileService.test.ts b/tests/unit/cli/services/FileService.test.ts similarity index 99% rename from tests/cli/services/FileService.test.ts rename to tests/unit/cli/services/FileService.test.ts index ac77ca98a..f551f19a4 100644 --- a/tests/cli/services/FileService.test.ts +++ b/tests/unit/cli/services/FileService.test.ts @@ -137,7 +137,7 @@ describe('FileService', () => { expect(result.missingPaths).toEqual(['gone.md']); }); - // Java looked files up by their path inside the branch, so '--file' stays branch-relative and + // Files are looked up by their path inside the branch, so '--file' stays branch-relative and // the branch itself only ever arrives through '--branch'. test('matches a branch-relative path against the branch-prefixed server path', async () => { spyOn(apiClient.sourceFilesApi, 'listProjectFiles').mockResolvedValue({ diff --git a/tests/cli/services/GlossaryService.test.ts b/tests/unit/cli/services/GlossaryService.test.ts similarity index 100% rename from tests/cli/services/GlossaryService.test.ts rename to tests/unit/cli/services/GlossaryService.test.ts diff --git a/tests/cli/services/LabelService.test.ts b/tests/unit/cli/services/LabelService.test.ts similarity index 100% rename from tests/cli/services/LabelService.test.ts rename to tests/unit/cli/services/LabelService.test.ts diff --git a/tests/cli/services/LanguageService.test.ts b/tests/unit/cli/services/LanguageService.test.ts similarity index 100% rename from tests/cli/services/LanguageService.test.ts rename to tests/unit/cli/services/LanguageService.test.ts diff --git a/tests/cli/services/ProgressService.test.ts b/tests/unit/cli/services/ProgressService.test.ts similarity index 100% rename from tests/cli/services/ProgressService.test.ts rename to tests/unit/cli/services/ProgressService.test.ts diff --git a/tests/cli/services/ProjectService.test.ts b/tests/unit/cli/services/ProjectService.test.ts similarity index 100% rename from tests/cli/services/ProjectService.test.ts rename to tests/unit/cli/services/ProjectService.test.ts diff --git a/tests/cli/services/ScreenshotService.test.ts b/tests/unit/cli/services/ScreenshotService.test.ts similarity index 100% rename from tests/cli/services/ScreenshotService.test.ts rename to tests/unit/cli/services/ScreenshotService.test.ts diff --git a/tests/cli/services/StorageService.test.ts b/tests/unit/cli/services/StorageService.test.ts similarity index 92% rename from tests/cli/services/StorageService.test.ts rename to tests/unit/cli/services/StorageService.test.ts index 5fe838186..01e364078 100644 --- a/tests/cli/services/StorageService.test.ts +++ b/tests/unit/cli/services/StorageService.test.ts @@ -23,7 +23,7 @@ describe('StorageService', () => { test('addStorage uploads binary payload for image files', async () => { const localFilePath = join(tempDir, 'test-screenshot.png'); - await Bun.write(localFilePath, Bun.file('tests/fixtures/services/StorageService/screenshot.png')); + await Bun.write(localFilePath, Bun.file('tests/unit/fixtures/services/StorageService/screenshot.png')); const addStorageSpy = spyOn(apiClient.uploadStorageApi, 'addStorage').mockResolvedValue({ data: { id: 44, fileName: 'test-screenshot.png' }, diff --git a/tests/cli/services/StringService.test.ts b/tests/unit/cli/services/StringService.test.ts similarity index 100% rename from tests/cli/services/StringService.test.ts rename to tests/unit/cli/services/StringService.test.ts diff --git a/tests/cli/services/TmService.test.ts b/tests/unit/cli/services/TmService.test.ts similarity index 100% rename from tests/cli/services/TmService.test.ts rename to tests/unit/cli/services/TmService.test.ts diff --git a/tests/cli/services/TranslationService.test.ts b/tests/unit/cli/services/TranslationService.test.ts similarity index 100% rename from tests/cli/services/TranslationService.test.ts rename to tests/unit/cli/services/TranslationService.test.ts diff --git a/tests/cli/utils/aiContext.test.ts b/tests/unit/cli/utils/aiContext.test.ts similarity index 98% rename from tests/cli/utils/aiContext.test.ts rename to tests/unit/cli/utils/aiContext.test.ts index 3627ad8da..0d5fefd07 100644 --- a/tests/cli/utils/aiContext.test.ts +++ b/tests/unit/cli/utils/aiContext.test.ts @@ -77,7 +77,7 @@ describe('aiContext', () => { expect(getStringText('hello')).toBe('hello'); }); - test('flattens plural text the same way as the Java CLI', () => { + test('flattens plural text into one line', () => { expect(getStringText({ one: 'apple', other: 'apples' })).toBe('one: apple | other: apples'); }); diff --git a/tests/cli/utils/argFiles.test.ts b/tests/unit/cli/utils/argFiles.test.ts similarity index 97% rename from tests/cli/utils/argFiles.test.ts rename to tests/unit/cli/utils/argFiles.test.ts index 9e8b15246..cf349048b 100644 --- a/tests/cli/utils/argFiles.test.ts +++ b/tests/unit/cli/utils/argFiles.test.ts @@ -24,7 +24,7 @@ describe('expandArgFiles', () => { expect(expandArgFiles([`@${path}`, '--verbose'])).toEqual(['upload', 'sources', '-b', 'main', '--verbose']); }); - test('splits args on whitespace (default picocli mode, not one-per-line)', () => { + test('splits args on whitespace (not one-per-line)', () => { const path = file('spaced.txt', 'upload sources\t-b main\n'); expect(expandArgFiles([`@${path}`])).toEqual(['upload', 'sources', '-b', 'main']); }); diff --git a/tests/cli/utils/browserAuth.test.ts b/tests/unit/cli/utils/browserAuth.test.ts similarity index 100% rename from tests/cli/utils/browserAuth.test.ts rename to tests/unit/cli/utils/browserAuth.test.ts diff --git a/tests/cli/utils/checkVersion.test.ts b/tests/unit/cli/utils/checkVersion.test.ts similarity index 100% rename from tests/cli/utils/checkVersion.test.ts rename to tests/unit/cli/utils/checkVersion.test.ts diff --git a/tests/cli/utils/downloadToFile.test.ts b/tests/unit/cli/utils/downloadToFile.test.ts similarity index 100% rename from tests/cli/utils/downloadToFile.test.ts rename to tests/unit/cli/utils/downloadToFile.test.ts diff --git a/tests/cli/utils/fileTree.test.ts b/tests/unit/cli/utils/fileTree.test.ts similarity index 100% rename from tests/cli/utils/fileTree.test.ts rename to tests/unit/cli/utils/fileTree.test.ts diff --git a/tests/cli/utils/localPath.test.ts b/tests/unit/cli/utils/localPath.test.ts similarity index 100% rename from tests/cli/utils/localPath.test.ts rename to tests/unit/cli/utils/localPath.test.ts diff --git a/tests/cli/utils/open.test.ts b/tests/unit/cli/utils/open.test.ts similarity index 100% rename from tests/cli/utils/open.test.ts rename to tests/unit/cli/utils/open.test.ts diff --git a/tests/cli/utils/output.test.ts b/tests/unit/cli/utils/output.test.ts similarity index 93% rename from tests/cli/utils/output.test.ts rename to tests/unit/cli/utils/output.test.ts index 7237e226f..1da701d37 100644 --- a/tests/cli/utils/output.test.ts +++ b/tests/unit/cli/utils/output.test.ts @@ -92,6 +92,22 @@ describe('machine output keys', () => { }); describe('diagnostics', () => { + /** stderr writes with the newline each one ends in stripped, one entry per diagnostic. */ + function captureStderr(): string[] { + const errors: string[] = []; + + spyOn(process.stderr, 'write').mockImplementation((chunk) => { + errors.push(String(chunk).replace(/\n$/, '')); + return true; + }); + + return errors; + } + + afterEach(() => { + (process.stderr.write as ReturnType<typeof spyOn>).mockRestore?.(); + }); + const options = (output: string): GlobalOptions => ({ colors: false, config: '', @@ -105,7 +121,7 @@ describe('diagnostics', () => { // still leaves parseable output behind. test.each(['json', 'toon', 'text', 'plain'])('keeps diagnostics off stdout in %s', (format) => { const log = spyOn(console, 'log').mockImplementation(() => {}); - spyOn(console, 'error').mockImplementation(() => {}); + captureStderr(); const out = createOutput(options(format)); @@ -116,11 +132,7 @@ describe('diagnostics', () => { }); test('emits one JSON object per diagnostic in machine formats', () => { - const errors: string[] = []; - - spyOn(console, 'error').mockImplementation((line) => { - errors.push(String(line)); - }); + const errors = captureStderr(); const out = createOutput(options('json')); @@ -139,29 +151,20 @@ describe('diagnostics', () => { // toon records span lines, so a blank line ends each one. Newlines inside a message are // escaped by both formats, so neither separator can turn up inside a record. test('emits toon blocks separated by a blank line when the output format is toon', () => { - const errors: string[] = []; - - spyOn(console, 'error').mockImplementation((line) => { - errors.push(String(line)); - }); + const errors = captureStderr(); const out = createOutput(options('toon')); out.warning('a warning'); out.error('a failure', { code: 1 }); - // console.error appends the newline the spy does not capture, so add it back. expect(errors.map((line) => `${line}\n`).join('')).toBe( 'level: warning\nmessage: a warning\n\nlevel: error\nmessage: a failure\ncode: 1\n\n', ); }); test('escapes a newline inside a message rather than ending the record', () => { - const errors: string[] = []; - - spyOn(console, 'error').mockImplementation((line) => { - errors.push(String(line)); - }); + const errors = captureStderr(); createOutput(options('toon')).warning('first line\n\nsecond line'); @@ -175,21 +178,21 @@ describe('diagnostics', () => { // Warnings used to be dropped outside text, so --output consumers lost them silently. test('reports warnings in every format', () => { for (const format of ['json', 'toon', 'text', 'plain']) { - const error = spyOn(console, 'error').mockImplementation(() => {}); + const errors = captureStderr(); createOutput(options(format)).warning('a warning'); - expect(error).toHaveBeenCalledWith(expect.stringContaining('a warning')); - error.mockRestore(); + expect(errors).toEqual([expect.stringContaining('a warning')]); + (process.stderr.write as ReturnType<typeof spyOn>).mockRestore(); } }); test('leaves the symbol off the plain line', () => { - const error = spyOn(console, 'error').mockImplementation(() => {}); + const errors = captureStderr(); createOutput(options('plain')).error('a failure'); - expect(error).toHaveBeenCalledWith('a failure'); + expect(errors).toEqual(['a failure']); }); }); diff --git a/tests/cli/utils/parsing.test.ts b/tests/unit/cli/utils/parsing.test.ts similarity index 54% rename from tests/cli/utils/parsing.test.ts rename to tests/unit/cli/utils/parsing.test.ts index 7766e8d9b..c8664b8d8 100644 --- a/tests/cli/utils/parsing.test.ts +++ b/tests/unit/cli/utils/parsing.test.ts @@ -1,11 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { ExitCode, getExitCode } from '@/cli/errors/CliError.ts'; -import { normalizeBranchName, normalizePath, parseNumericId, toNumberArray } from '@/cli/utils/parsing.ts'; +import { normalizeBranchName, normalizePath, parseNumericId, parseScheme, toNumberArray } from '@/cli/utils/parsing.ts'; -// Java declares these ids as Long, so picocli's Long.parseLong rejects anything else with a usage -// error. Number() would accept all of the below and forward junk to the API as a real id. +// Ids must be plain integers; anything else is a usage error. Number() would accept all of the below and forward junk to the API as a real id. describe('parseNumericId', () => { - test('accepts the integer forms Long.parseLong does', () => { + test('accepts signed integer forms', () => { expect(parseNumericId('12', 'Bundle')).toBe(12); expect(parseNumericId('-3', 'Bundle')).toBe(-3); expect(parseNumericId('+7', 'Bundle')).toBe(7); @@ -15,7 +14,7 @@ describe('parseNumericId', () => { expect(() => parseNumericId(value, 'Bundle')).toThrow('Bundle id'); }); - test('rejects with the validation exit code, as picocli does', () => { + test('rejects with the validation exit code', () => { try { parseNumericId('1.5', 'Bundle'); throw new Error('expected parseNumericId to throw'); @@ -25,6 +24,47 @@ describe('parseNumericId', () => { }); }); +describe('parseScheme', () => { + const columns = { ar: 1, de: 2, en: 3 }; + + test('reads the repeated-flag spelling', () => { + expect(parseScheme(['ar=1', 'de=2', 'en=3'])).toEqual(columns); + }); + + test('reads the comma-joined spelling, and a mix of the two', () => { + expect(parseScheme(['ar=1,de=2,en=3'])).toEqual(columns); + expect(parseScheme(['ar=1,de=2', 'en=3'])).toEqual(columns); + }); + + test('keeps a hyphenated locale as the column name', () => { + expect(parseScheme(['zh-CN=5'])).toEqual({ 'zh-CN': 5 }); + }); + + test('treats no scheme as absent rather than empty', () => { + // The upload actions branch on `undefined` to decide whether to send `scheme` at all. + expect(parseScheme([])).toBeUndefined(); + }); + + test('accepts column zero', () => { + expect(parseScheme(['en=0'])).toEqual({ en: 0 }); + }); + + // 'en=' is in the list because `Number('')` is 0, which clears the integer guard on its own - the + // reason parseScheme tests `!column` rather than `column === undefined`. + test.each(['en', 'en=', '=1', 'en=x', 'en=1.5', 'en=-1', 'en=1=2'])('rejects %p', (value) => { + expect(() => parseScheme([value])).toThrow("The '--scheme' parameter has an invalid value"); + }); + + test('rejects with the validation exit code', () => { + try { + parseScheme(['en']); + throw new Error('expected parseScheme to throw'); + } catch (error) { + expect(getExitCode(error)).toBe(ExitCode.VALIDATION); + } + }); +}); + describe('toNumberArray', () => { test('accepts integers and passes through real numbers', () => { expect(toNumberArray(['1', '2'], 'bad')).toEqual([1, 2]); diff --git a/tests/cli/utils/pathMatcher.test.ts b/tests/unit/cli/utils/pathMatcher.test.ts similarity index 100% rename from tests/cli/utils/pathMatcher.test.ts rename to tests/unit/cli/utils/pathMatcher.test.ts diff --git a/tests/cli/utils/proxy.test.ts b/tests/unit/cli/utils/proxy.test.ts similarity index 100% rename from tests/cli/utils/proxy.test.ts rename to tests/unit/cli/utils/proxy.test.ts diff --git a/tests/cli/utils/userAgent.test.ts b/tests/unit/cli/utils/userAgent.test.ts similarity index 91% rename from tests/cli/utils/userAgent.test.ts rename to tests/unit/cli/utils/userAgent.test.ts index 7ea68a873..4ad357aad 100644 --- a/tests/cli/utils/userAgent.test.ts +++ b/tests/unit/cli/utils/userAgent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import os from 'node:os'; import { buildUserAgent } from '@/cli/utils/userAgent.ts'; -import packageJson from '../../../package.json'; +import packageJson from '../../../../package.json'; describe('buildUserAgent', () => { test('builds user agent from app version and os info', () => { diff --git a/tests/cli/utils/withSpinner.test.ts b/tests/unit/cli/utils/withSpinner.test.ts similarity index 100% rename from tests/cli/utils/withSpinner.test.ts rename to tests/unit/cli/utils/withSpinner.test.ts diff --git a/tests/fixtures/services/StorageService/screenshot.png b/tests/unit/fixtures/services/StorageService/screenshot.png similarity index 100% rename from tests/fixtures/services/StorageService/screenshot.png rename to tests/unit/fixtures/services/StorageService/screenshot.png diff --git a/tests/lib/api/pollStatus.test.ts b/tests/unit/lib/api/pollStatus.test.ts similarity index 100% rename from tests/lib/api/pollStatus.test.ts rename to tests/unit/lib/api/pollStatus.test.ts diff --git a/tests/lib/config.test.ts b/tests/unit/lib/config.test.ts similarity index 92% rename from tests/lib/config.test.ts rename to tests/unit/lib/config.test.ts index adf2162df..739e4256f 100644 --- a/tests/lib/config.test.ts +++ b/tests/unit/lib/config.test.ts @@ -24,7 +24,7 @@ describe('files section optional + guard', () => { expect(ConfigSchema.parse(credsOnly).files).toEqual([]); }); - test('assertFilesConfigured throws Java-parity message when files empty', () => { + test('assertFilesConfigured throws when files empty', () => { expect(() => assertFilesConfigured(ConfigSchema.parse(credsOnly))).toThrow( "Required section 'files' is missing (or empty) in the configuration file", ); @@ -35,9 +35,9 @@ describe('files section optional + guard', () => { }); }); -// Java FileBean.populateWithDefaultValues normalizes the file section at config load, so every +// The file section is normalized at config load, so every // consumer reads settled values instead of re-deriving them. -describe('ConfigSchema files[] path normalization (Java FileBean parity)', () => { +describe('ConfigSchema files[] path normalization', () => { const parseFile = (overrides: Record<string, unknown>, configOverrides: Record<string, unknown> = {}) => ConfigSchema.parse({ ...baseConfig(overrides), ...configOverrides }).files[0]; @@ -157,7 +157,7 @@ describe('ConfigSchema files[] parity fields', () => { expect(config.files[0]?.scheme).toEqual({ identifier: 0, source_phrase: 1 }); }); - test('maps documented Java update_option values to the API enum', () => { + test('maps documented update_option values to the API enum', () => { expect(ConfigSchema.parse(baseConfig({ update_option: 'update_as_unapproved' })).files[0]?.update_option).toBe( 'keep_translations', ); @@ -178,7 +178,7 @@ describe('ConfigSchema files[] parity fields', () => { expect(() => ConfigSchema.parse(baseConfig({ escape_special_characters: 2 }))).toThrow(); }); - test('defaults preserveHierarchy to false to match the Java CLI', () => { + test('defaults preserveHierarchy to false', () => { const config = ConfigSchema.parse(baseConfig()); expect(config.preserveHierarchy).toBe(false); @@ -229,7 +229,7 @@ describe('ConfigSchema files[] parity fields', () => { expect(config.files[0]?.dest).toBe('/foo/strings.json'); }); - // Java validates both of these at config load (FileBean.checkProperties), so they are validation + // Both are validated at config load, so they are validation // errors rather than runtime failures raised later from path resolution. test('rejects ** in translation when source has none', () => { expect(() => @@ -265,7 +265,7 @@ describe('ConfigSchema files[] parity fields', () => { }); test('parses multilingual_spreadsheet without relaxing the placeholder requirement', () => { - // multilingual_spreadsheet is accepted for Java parity but does not mark the file as multilingual. + // multilingual_spreadsheet is accepted but does not mark the file as multilingual. expect(() => ConfigSchema.parse(baseConfig({ translation: '/locale/strings.xml', multilingual_spreadsheet: true })), ).toThrow('should contain at least one language placeholder'); @@ -275,7 +275,7 @@ describe('ConfigSchema files[] parity fields', () => { }); }); -describe('ConfigSchema boolean coercion (Java setBooleanPropertyIfExists parity)', () => { +describe('ConfigSchema boolean coercion', () => { test('coerces 0/1 (and string forms) to booleans', () => { const config = ConfigSchema.parse({ ...baseConfig({ content_segmentation: 1, import_translations: 0 }), @@ -294,7 +294,7 @@ describe('ConfigSchema boolean coercion (Java setBooleanPropertyIfExists parity) }); }); -describe('ConfigSchema base_url (Java isUrlValid / normalization parity)', () => { +describe('ConfigSchema base_url validation and normalization', () => { const withBaseUrl = (baseUrl: string) => ({ ...baseConfig(), baseUrl }); const parseBaseUrl = (baseUrl: string) => ConfigSchema.parse(withBaseUrl(baseUrl)).baseUrl; @@ -327,15 +327,15 @@ describe('ConfigSchema base_url (Java isUrlValid / normalization parity)', () => test.each([ 'https://evil.example.com', 'https://evilcrowdin.com', // no dot before crowdin.com - 'http://api.crowdin.com', // http rejected (Java requires https) + 'http://api.crowdin.com', // http rejected (https required) 'https://acme.crowdin.com/api/v3', // unknown suffix not normalized away ])('rejects %s', (url) => { expect(() => parseBaseUrl(url)).toThrow(); }); }); -// a present-but-short --token must not be rejected as an invalid config file. Java never -// length-checks the token (only rejects empty), letting the API return 401 for a bad one. +// a present-but-short --token must not be rejected as an invalid config file. The token is never +// length-checked (only empty is rejected), letting the API return 401 for a bad one. describe('ConfigSchema apiToken', () => { const parse = (apiToken: unknown) => ConfigSchema.safeParse({ projectId: 123, apiToken }); @@ -347,7 +347,7 @@ describe('ConfigSchema apiToken', () => { expect(ConfigSchema.safeParse({ projectId: 123 }).success).toBe(true); }); - test('rejects an empty token, matching Java missed_api_token', () => { + test('rejects an empty token', () => { const result = parse(''); expect(result.success).toBe(false); expect(result.error?.issues[0]?.message).toBe("Required option 'api_token' is missing"); diff --git a/tests/lib/config/SourceFileLoader.test.ts b/tests/unit/lib/config/SourceFileLoader.test.ts similarity index 97% rename from tests/lib/config/SourceFileLoader.test.ts rename to tests/unit/lib/config/SourceFileLoader.test.ts index aa8d8ce71..ab636f878 100644 --- a/tests/lib/config/SourceFileLoader.test.ts +++ b/tests/unit/lib/config/SourceFileLoader.test.ts @@ -39,7 +39,7 @@ describe('SourceFileLoader', () => { }); // Exercised through the public API: file placeholders in `ignore` are expanded per scanned - // source file (Java PlaceholderUtil.format), so a pattern can match files it names indirectly. + // source file, so a pattern can match files it names indirectly. describe('file placeholders in ignore patterns', () => { test('passes through patterns without file placeholders', async () => { await Bun.write(`${tempDir}/a/app.json`, '{}'); @@ -118,7 +118,7 @@ describe('SourceFileLoader', () => { }); test('excludes files matching an ignore pattern with file placeholders', async () => { - // Java parity: file placeholders resolve per scanned source file (the pre-filter list), so + // File placeholders resolve per scanned source file (the pre-filter list), so // every file under backup/ names itself into the expanded ignore set and is excluded. await Bun.write(`${tempDir}/strings.xml`, '<x/>'); await Bun.write(`${tempDir}/backup/strings.xml`, '<x/>'); diff --git a/tests/lib/config/projectFileMatch.test.ts b/tests/unit/lib/config/projectFileMatch.test.ts similarity index 97% rename from tests/lib/config/projectFileMatch.test.ts rename to tests/unit/lib/config/projectFileMatch.test.ts index 795a2faa4..4f13f443d 100644 --- a/tests/lib/config/projectFileMatch.test.ts +++ b/tests/unit/lib/config/projectFileMatch.test.ts @@ -17,7 +17,7 @@ describe('globToRegex', () => { expect(globToRegex('%original_file_name%')).toBe('[^/]+'); }); - // Java PlaceholderUtil:308 rewrites `.+/` to `(.+/)?` before substituting placeholders, so a + // `.+/` is rewritten to `(.+/)?` before substituting placeholders, so a // `**` segment is optional but an `%original_path%` segment is not. test('makes a ** segment optional and leaves %original_path% mandatory', () => { expect(globToRegex('src/**/*.json')).toBe('src/(.+/)?[^/]+\\.json'); @@ -25,7 +25,7 @@ describe('globToRegex', () => { expect(globToRegex('%original_path%/*.json')).toBe('.+/[^/]+\\.json'); }); - // Java never escapes brackets, and Bun's Glob (which scans the local sources) honours sets, so + // Brackets are never escaped: Bun's Glob (which scans the local sources) honours sets, so // escaping them here made the server-side matcher disagree with the local scan. test('passes character sets through as regex classes', () => { expect(globToRegex('file[12].json')).toBe('file[12]\\.json'); diff --git a/tests/lib/config/translationPathResolver.test.ts b/tests/unit/lib/config/translationPathResolver.test.ts similarity index 99% rename from tests/lib/config/translationPathResolver.test.ts rename to tests/unit/lib/config/translationPathResolver.test.ts index 88b2e023f..b40c902bc 100644 --- a/tests/lib/config/translationPathResolver.test.ts +++ b/tests/unit/lib/config/translationPathResolver.test.ts @@ -335,7 +335,7 @@ describe('translation path resolver', () => { expect(actual).toBe('/translated/es/readme.md'); }); - // The dest branch of Java's doTranslationMapping only fires when `translation` has no language + // The dest-derived mapping only applies when `translation` has no language // placeholder, which the schema allows only for multilingual/scheme files. test('expands ** in the dest-derived archive key so it matches the path upload creates', async () => { const basePath = await mkdtemp(); diff --git a/tests/lib/config/yamlGenerator.test.ts b/tests/unit/lib/config/yamlGenerator.test.ts similarity index 100% rename from tests/lib/config/yamlGenerator.test.ts rename to tests/unit/lib/config/yamlGenerator.test.ts diff --git a/tests/lib/config/yamlLoader.test.ts b/tests/unit/lib/config/yamlLoader.test.ts similarity index 87% rename from tests/lib/config/yamlLoader.test.ts rename to tests/unit/lib/config/yamlLoader.test.ts index cae3b7030..4503fe2cb 100644 --- a/tests/lib/config/yamlLoader.test.ts +++ b/tests/unit/lib/config/yamlLoader.test.ts @@ -20,10 +20,10 @@ describe('parseYaml', () => { expect(() => parseYaml('')).toThrow(InvalidConfigurationError); }); - // Java (SnakeYAML) accepts a flow collection whose lines are not indented past its block key, and - // real Crowdin configs are written this way. Spec-strict parsers reject it: the `yaml` package with + // Crowdin configs may hold a flow collection whose lines are not indented past its block key, and + // real configs are written this way. Spec-strict parsers reject it: the `yaml` package with // BAD_INDENT, js-yaml 5 with "deficient indentation". Both would drop the second entry or throw. - test('parses a flow collection dedented to column 0, as SnakeYAML does', () => { + test('parses a flow collection dedented to column 0', () => { const raw = parseYaml( [ 'files: [{', @@ -66,11 +66,11 @@ describe('mapConfig', () => { expect(out).not.toHaveProperty('api_token_env'); }); - test('reads ignore_hidden_files nested under the settings block (Java SettingsBean)', () => { + test('reads ignore_hidden_files nested under the settings block', () => { expect(mapConfig({ settings: { ignore_hidden_files: false } }).ignoreHiddenFiles).toBe(false); }); - test('ignores a top-level ignore_hidden_files (Java only nests it under settings)', () => { + test('ignores a top-level ignore_hidden_files (only read under settings)', () => { expect(mapConfig({ ignore_hidden_files: false }).ignoreHiddenFiles).toBeUndefined(); }); }); diff --git a/tests/lib/download/languages.test.ts b/tests/unit/lib/download/languages.test.ts similarity index 96% rename from tests/lib/download/languages.test.ts rename to tests/unit/lib/download/languages.test.ts index 137221689..092fe416d 100644 --- a/tests/lib/download/languages.test.ts +++ b/tests/unit/lib/download/languages.test.ts @@ -10,7 +10,7 @@ describe('resolveDownloadLanguages', () => { const result = resolveDownloadLanguages(projectLanguages, {}); expect(ids(result)).toEqual(['de', 'fr', 'uk']); - // Java leaves targetLanguageIds off the build request when the set was never narrowed. + // targetLanguageIds stays off the build request when the set was never narrowed. expect(result.languageIds).toBeUndefined(); }); diff --git a/tests/lib/download/projectTranslations.test.ts b/tests/unit/lib/download/projectTranslations.test.ts similarity index 100% rename from tests/lib/download/projectTranslations.test.ts rename to tests/unit/lib/download/projectTranslations.test.ts diff --git a/tests/lib/export/languagePlaceholders.test.ts b/tests/unit/lib/export/languagePlaceholders.test.ts similarity index 97% rename from tests/lib/export/languagePlaceholders.test.ts rename to tests/unit/lib/export/languagePlaceholders.test.ts index 88431a070..3c4af8198 100644 --- a/tests/lib/export/languagePlaceholders.test.ts +++ b/tests/unit/lib/export/languagePlaceholders.test.ts @@ -36,7 +36,7 @@ describe('%android_code% placeholder', () => { describe('%locale_with_underscore% placeholder', () => { test('replaces every separator, not just the first', () => { - // Java's String.replace is global, and three-part locales reach this path. + // Three-part locales reach this path. const serbian = { id: 'sr', name: 'Serbian', locale: 'sr-Cyrl-RS' } as LanguagesModel.Language; expect(resolveLanguagePlaceholders('/l/%locale_with_underscore%/app.json', serbian)).toBe('/l/sr_Cyrl_RS/app.json'); diff --git a/tests/lib/export/patterns.test.ts b/tests/unit/lib/export/patterns.test.ts similarity index 100% rename from tests/lib/export/patterns.test.ts rename to tests/unit/lib/export/patterns.test.ts diff --git a/tests/lib/identityFiles.test.ts b/tests/unit/lib/identityFiles.test.ts similarity index 100% rename from tests/lib/identityFiles.test.ts rename to tests/unit/lib/identityFiles.test.ts diff --git a/tests/lib/organization/credentials.test.ts b/tests/unit/lib/organization/credentials.test.ts similarity index 100% rename from tests/lib/organization/credentials.test.ts rename to tests/unit/lib/organization/credentials.test.ts diff --git a/tests/lib/upload/fileLookup.test.ts b/tests/unit/lib/upload/fileLookup.test.ts similarity index 100% rename from tests/lib/upload/fileLookup.test.ts rename to tests/unit/lib/upload/fileLookup.test.ts diff --git a/tests/lib/upload/fileOptions.test.ts b/tests/unit/lib/upload/fileOptions.test.ts similarity index 96% rename from tests/lib/upload/fileOptions.test.ts rename to tests/unit/lib/upload/fileOptions.test.ts index 124f3ca82..dadba44d4 100644 --- a/tests/lib/upload/fileOptions.test.ts +++ b/tests/unit/lib/upload/fileOptions.test.ts @@ -144,7 +144,7 @@ describe('%original_path%', () => { }); }); -// Java PlaceholderUtil.replaceFileDependentPlaceholders:234-249 expands `**` in dest/context from +// `**` in dest/context expands from // the source file's parent path. Distinct from replaceDoubleAsterisk, which serves `translation`. describe('** in dest and context', () => { test('expands to the full parent path when the prefix is unrelated to it', () => { @@ -175,7 +175,7 @@ describe('** in dest and context', () => { expect(prepareDest('/out/%original_file_name%', 'src/nested/app.json')).toBe('out/app.json'); }); - // Java's String.replace(CharSequence, CharSequence) substitutes every occurrence. Replacing only + // Every occurrence is substituted. Replacing only // the first left a literal `**` in the project path — the very bug this expansion exists to avoid. test('substitutes every wildcard in the pattern, not just the first', () => { expect(prepareDest('/out/**/mid/**/%original_file_name%', 'src/nested/deep/app.json')).toBe( @@ -183,7 +183,7 @@ describe('** in dest and context', () => { ); }); - // The tail after `**` appears mid-path, so Java re-extends it to cover the whole remainder. + // The tail after `**` appears mid-path, so it is re-extended to cover the whole remainder. test('extends the tail when it appears midway through the file path', () => { expect(prepareDest('/out/**/nested/%original_file_name%', 'src/nested/deep/nested/app.json')).toBe( 'out/src/nested/deep/nested/app.json', diff --git a/tests/lib/upload/obsoleteEntries.test.ts b/tests/unit/lib/upload/obsoleteEntries.test.ts similarity index 95% rename from tests/lib/upload/obsoleteEntries.test.ts rename to tests/unit/lib/upload/obsoleteEntries.test.ts index 57d594eb4..484211e1d 100644 --- a/tests/lib/upload/obsoleteEntries.test.ts +++ b/tests/unit/lib/upload/obsoleteEntries.test.ts @@ -39,7 +39,7 @@ async function deleteObsolete(options: { } describe('deleteObsoleteProjectEntries', () => { - // Java builds its directory candidates from the files it just deleted, so a directory nobody's + // Directory candidates come from the files just deleted, so a directory nobody's // config references (created in the Crowdin UI, say) is never touched. test('leaves an unrelated empty directory alone', async () => { const result = await deleteObsolete({ @@ -80,7 +80,7 @@ describe('deleteObsoleteProjectEntries', () => { expect(result.deletedFiles).toEqual([]); }); - // Matching runs on the config-pattern matcher (Java's formatSourcePatternForRegex machinery), + // Matching runs on the config-pattern matcher, // which expands file placeholders. The CLI-filter matcher this used to call does not, so a // placeholder `source` matched nothing and its obsolete files were never cleaned up. test('treats a file as managed when the source pattern carries a file placeholder', async () => { diff --git a/tests/lib/utils/concurrency.test.ts b/tests/unit/lib/utils/concurrency.test.ts similarity index 100% rename from tests/lib/utils/concurrency.test.ts rename to tests/unit/lib/utils/concurrency.test.ts diff --git a/tests/lib/utils/doubleAsterisk.test.ts b/tests/unit/lib/utils/doubleAsterisk.test.ts similarity index 96% rename from tests/lib/utils/doubleAsterisk.test.ts rename to tests/unit/lib/utils/doubleAsterisk.test.ts index 3866dac05..cf10b98ff 100644 --- a/tests/lib/utils/doubleAsterisk.test.ts +++ b/tests/unit/lib/utils/doubleAsterisk.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from 'bun:test'; import { expandDestDoubleAsterisk, replaceDoubleAsterisk } from '@/lib/utils/doubleAsterisk.ts'; describe('replaceDoubleAsterisk', () => { - // Ported verbatim from Java TranslationsUtilsTest.testReplaceDoubleAsterisk. const cases: [source: string, translation: string, sourceFile: string, expected: string][] = [ [ '/folder/**/*.txt', @@ -88,7 +87,7 @@ describe('replaceDoubleAsterisk', () => { ); }); - // Java's String.replace is literal; a JS string replacement would read `$&` as the match. + // A JS string replacement would read `$&` as the match. test('substitutes a directory containing $ literally', () => { expect(replaceDoubleAsterisk('/folder/**/*.txt', '/f/**/%original_file_name%', 'folder/$&x/file.txt')).toBe( '/f/$&x/%original_file_name%', diff --git a/tests/lib/utils/path.test.ts b/tests/unit/lib/utils/path.test.ts similarity index 100% rename from tests/lib/utils/path.test.ts rename to tests/unit/lib/utils/path.test.ts diff --git a/tests/npm/launcher.test.ts b/tests/unit/npm/launcher.test.ts similarity index 96% rename from tests/npm/launcher.test.ts rename to tests/unit/npm/launcher.test.ts index 19445ceb5..13d54184e 100644 --- a/tests/npm/launcher.test.ts +++ b/tests/unit/npm/launcher.test.ts @@ -1,10 +1,10 @@ import { afterAll, describe, expect, it } from 'bun:test'; import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import { isMusl, platformPackageName, resolveBinaryPath } from '../../packages/npm/cli/bin/launcher.js'; -import launcherPkg from '../../packages/npm/cli/package.json'; +import { isMusl, platformPackageName, resolveBinaryPath } from '../../../packages/npm/cli/bin/launcher.js'; +import launcherPkg from '../../../packages/npm/cli/package.json'; -const repoRoot = path.resolve(import.meta.dir, '../..'); +const repoRoot = path.resolve(import.meta.dir, '../../..'); const launcherEntry = './packages/npm/cli/bin/launcher.js'; describe('platformPackageName', () => { diff --git a/tests/npm/platform-packages.test.ts b/tests/unit/npm/platform-packages.test.ts similarity index 92% rename from tests/npm/platform-packages.test.ts rename to tests/unit/npm/platform-packages.test.ts index 4ae676b35..ebeda962e 100644 --- a/tests/npm/platform-packages.test.ts +++ b/tests/unit/npm/platform-packages.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'bun:test'; import { readFileSync } from 'node:fs'; import path from 'node:path'; -import rootPkg from '../../package.json'; -import launcherPkg from '../../packages/npm/cli/package.json'; +import rootPkg from '../../../package.json'; +import launcherPkg from '../../../packages/npm/cli/package.json'; interface PlatformManifest { name: string; @@ -16,7 +16,7 @@ interface PlatformManifest { publishConfig: { access: string }; } -const packagesRoot = path.resolve(import.meta.dir, '../../packages/npm'); +const packagesRoot = path.resolve(import.meta.dir, '../../../packages/npm'); const platformNames = Object.keys(launcherPkg.optionalDependencies); function loadManifest(packageName: string): PlatformManifest { diff --git a/tests/scripts/generateDocs.test.ts b/tests/unit/scripts/generateDocs.test.ts similarity index 100% rename from tests/scripts/generateDocs.test.ts rename to tests/unit/scripts/generateDocs.test.ts