diff --git a/README.md b/README.md index a542490..3f93a7f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The minimum supported server is **Metabase v0.58** (major `58`). Anything older Commands that need more than a baseline OSS server declare it — a higher minimum major version or a premium token feature. The server version and token features are detected and cached when you run `mb auth login` (or `mb auth list`). For those commands, a preflight check runs before the first request and refuses with an actionable message (exit code `2`) when: - the server is older than the command's minimum version, or -- the command needs a premium feature (e.g. `remote_sync`, `library`) that isn't enabled. +- the command needs a premium feature (e.g. `remote_sync`, `content_translation`, `library`) that isn't enabled. Plain OSS commands against a v0.58+ server (the majority) carry no elevated requirement and skip the preflight entirely. When a gated command runs but the server version can't be detected (no cached probe), it proceeds with a warning rather than refusing. To bypass the check for a single run, pass `--skip-preflight`; to bypass it process-wide (e.g. in CI), set `MB_CLI_SKIP_PREFLIGHT=1`. Both are footguns — only for servers you know are patched. @@ -582,6 +582,32 @@ mb upload replace 42 --file rows.csv mb upload replace 42 --file rows.csv --json ``` +## Content translation + +Download and replace Metabase's content translation dictionary through `/api/ee/content-translation`. These commands require an admin credential and the `content_translation` premium feature. The dictionary is independent of Remote Sync: importing or exporting a Git repository does not deploy it. + +### `mb content-translation download` + +Stream the complete active dictionary as CSV. Redirect stdout to keep it as a file. + +```sh +mb content-translation download > metabase-content-translations.csv +mb content-translation download --profile prod > translations.csv +``` + +### `mb content-translation upload` + +Replace every active content translation with one complete CSV. The upload is not a partial merge; keep the canonical complete dictionary in version control and download the active dictionary before replacing it. Metabase accepts dictionaries up to 1.5 MiB. + +```sh +mb content-translation upload --file translations.csv +mb content-translation upload --file translations.csv --profile prod --json +``` + +| Flag | Description | +| --------------- | ------------------------------------------------------- | +| `--file ` | Complete content translation dictionary CSV (required). | + ## Cards CRUD plus query execution on `/api/card`. A "card" is a Metabase question, model, or metric. The `query` subcommand runs the card and either returns Metabase's JSON envelope or streams a raw CSV / XLSX export. diff --git a/packages/cli/skill-data/core/SKILL.md b/packages/cli/skill-data/core/SKILL.md index 2d91bca..f3b7860 100644 --- a/packages/cli/skill-data/core/SKILL.md +++ b/packages/cli/skill-data/core/SKILL.md @@ -1,6 +1,6 @@ --- name: core -description: Foundations for driving Metabase from the terminal with the `mb` CLI — authentication and named profiles, the flag/output/`--json` conventions every command shares, JSON body input, command discovery via `--help` (add `--json` for machine-readable schemas), and the per-resource footguns (db, table, field, upload, card, dashboard, collection, segment, measure, timeline, alert, subscription, library, setting, search, eid). Load first for any `mb` task; it routes to the specialized skills for deeper work. +description: Foundations for driving Metabase from the terminal with the `mb` CLI — authentication and named profiles, the flag/output/`--json` conventions every command shares, JSON body input, command discovery via `--help` (add `--json` for machine-readable schemas), and the per-resource footguns (db, table, field, upload, content translation, card, dashboard, collection, segment, measure, timeline, alert, subscription, library, setting, search, eid). Load first for any `mb` task; it routes to the specialized skills for deeper work. allowed-tools: Read, Write, Edit, Bash, AskUserQuestion --- @@ -11,7 +11,7 @@ The official Metabase CLI (`mb`) drives a Metabase instance over its REST API: a Top-level command groups (run `mb --help` to discover verbs): ``` -auth | db | table | field | upload | query | card | dashboard | snippet | segment | measure | collection | library +auth | db | table | field | upload | content-translation | query | card | dashboard | snippet | segment | measure | collection | library document | timeline | timeline-event | transform | transform-job | transform-tag | alert | subscription | setting search | git-sync | setup | eid | uuid | upgrade | skills ``` @@ -129,6 +129,7 @@ Routine verb shapes (list / get / create / update), every flag, and output schem - **table fields.** `table get` never returns fields on its own — pass `--include fields` (compact; the underlying query_metadata response also carries FK targets and dimensions, visible under `--full`) or use `table fields ` (list envelope). `table update` patches table-level metadata only; physical columns aren't editable. - **field has no `list`.** Fields are per-table — get them via `table get --include fields`. Never enumerate fields across a whole db (context blow-up). `field summary` is live cardinality `{field_id, count, distincts}`; `field values` is the cached distinct set (`has_more_values: true` ⇒ truncated cache). `field update` patches metadata only (`base_type` isn't editable) — this is where you set a column's `semantic_type` or foreign-key target. - **upload (CSV → tables).** `upload csv --file ` creates a new table + model (prints `{model_id, table_id}`); `upload append ` / `upload replace --file ` add to / overwrite a table **previously created by upload** (columns must match). The destination db+schema is admin-configured, not per-call — check with `mb setting get uploads-settings --json` (`db_id: null` ⇒ uploads off/unconfigured; needs admin to read). `--collection ` only sets the model's collection. Max 50 MB. Errors: **"The uploads database is not configured."** = no db has uploads enabled; **"Uploads are not enabled."** = the append/replace target isn't an uploaded table. +- **content-translation.** EE-only (`content_translation` premium feature), admin-only, and separate from Remote Sync. `content-translation download > translations.csv` streams the complete active dictionary; `content-translation upload --file translations.csv` replaces every active translation with the file's contents. Always upload the canonical complete CSV, never a partial patch. Metabase limits dictionaries to 1.5 MiB. - **card.** `dataset_query` is the **flat** `mbql/query` value, not a legacy `{type:"query",query:…}` envelope (→ `mbql`). `--export-format csv|xlsx` streams the raw export (pipe to a file), bypassing the JSON envelope. `archive` is the only delete; unarchive with `update --body '{"archived":false}'`. `visualization_settings` keys are scoped by `display` and aren't pre-flighted — see `visualization`. - **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard ` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; negative ids for new cards). `create` accepts the **same** `dashcards` array in its initial body — lay out the whole dashboard in one call: negative ids for new cards, and `card_id:null` plus a `visualization_settings.virtual_card` block (`{display:"text"|"heading"|"link"|…}`) for non-question cards. `create`/`update` pre-flight every positive `card_id` and exit **2** with `{ok:false,errors:[…]}` on a bad ref (non-bypassable). `dashboard get ` (or `--full`) hydrates dashcards/tabs; `list` omits them. **The grid is 24 columns wide:** each dashcard's `{col, row, size_x, size_y}` is in grid units — **full-width is `size_x: 24`** (`size_x: 12` is half a row, the usual cause of a card filling only half the width). Keep `col + size_x ≤ 24`, start a full-width stack's `col` at 0, and don't overlap (the server stores collisions as sent — no auto-fix). Layout patterns and per-chart default sizes → the `dashboard` skill; load it before composing any `dashcards` array. - **dashboard parameters (filters).** A dashboard's `parameters` array holds its filter widgets; they're part of the dashboard record, so read them with `dashboard get --fields parameters --json` (no separate verb). **Editing replaces the _whole_ array** (like dashcards), so it's a read-modify-write loop and omitting a parameter deletes it. A parameter only filters a card once it is **mapped** onto that dashcard's `parameter_mappings` — an unmapped parameter is an inert widget. `type` is a **closed enum**; an unlisted value is a hard parse error that echoes the full allowed set back to you. `dashboard parameter-values [--query ]` fetches a widget's selectable values (`{values, has_more_values}`; `--query` is a case-insensitive substring search). Parameter types, ids, mapping targets, and value sources → the `dashboard` skill; load it before authoring a `parameters` array. diff --git a/packages/cli/src/commands/content-translation/download.ts b/packages/cli/src/commands/content-translation/download.ts new file mode 100644 index 0000000..0bbe878 --- /dev/null +++ b/packages/cli/src/commands/content-translation/download.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import { pipeToStdout } from "../../output/stream"; +import { connectionFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +const ContentTranslationCsv = z.string().describe("The raw content translation dictionary CSV."); + +export default defineMetabaseCommand({ + meta: { + name: "download", + description: "Stream the complete content translation dictionary as CSV", + }, + details: + "Streams the active dictionary to stdout with the server's CSV formatting. Redirect stdout to preserve it as a file that can be reviewed, versioned, or uploaded later.", + capabilities: { minVersion: 58, tokenFeature: "content_translation" }, + args: { + ...profileFlag, + ...connectionFlags, + }, + outputSchema: ContentTranslationCsv, + examples: [ + "mb content-translation download > metabase-content-translations.csv", + "mb content-translation download --profile prod > translations.csv", + ], + async run({ getClient }) { + const mb = await getClient(); + const stream = await mb.contentTranslation.download(); + await pipeToStdout(stream); + }, +}); diff --git a/packages/cli/src/commands/content-translation/index.ts b/packages/cli/src/commands/content-translation/index.ts new file mode 100644 index 0000000..c1e1405 --- /dev/null +++ b/packages/cli/src/commands/content-translation/index.ts @@ -0,0 +1,10 @@ +import { defineCommandGroup } from "../group"; + +export default defineCommandGroup({ + name: "content-translation", + description: "Download or replace the content translation dictionary", + subCommands: { + download: () => import("./download").then((mod) => mod.default), + upload: () => import("./upload").then((mod) => mod.default), + }, +}); diff --git a/packages/cli/src/commands/content-translation/upload.ts b/packages/cli/src/commands/content-translation/upload.ts new file mode 100644 index 0000000..e4c99aa --- /dev/null +++ b/packages/cli/src/commands/content-translation/upload.ts @@ -0,0 +1,39 @@ +import { ContentTranslationUploadResult } from "@metabase/client/domain/content-translation"; + +import { renderSummary } from "../../output/render"; +import { contentTranslationUploadView } from "../../output/views/content-translation"; +import { readCsvFile, requireUploadFilePath } from "../../runtime/upload"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "upload", + description: "Replace the complete content translation dictionary from CSV", + }, + details: + "Uploads one complete dictionary and replaces every active content translation on the server. Keep the canonical full CSV in version control and download the current dictionary before replacing it. Metabase accepts dictionaries up to 1.5 MiB.", + capabilities: { minVersion: 58, tokenFeature: "content_translation" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + file: { type: "string", description: "Path to the complete translation dictionary CSV" }, + }, + outputSchema: ContentTranslationUploadResult, + examples: [ + "mb content-translation upload --file translations.csv", + "mb content-translation upload --file translations.csv --profile prod --json", + ], + async run({ args, ctx, getClient }) { + const file = await readCsvFile(requireUploadFilePath(args.file)); + const mb = await getClient(); + const result = await mb.contentTranslation.upload(file); + renderSummary( + result, + contentTranslationUploadView, + `Replaced the content translation dictionary with "${file.filename}".`, + ctx, + ); + }, +}); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 8765d50..420a0ac 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -17,6 +17,8 @@ const main: CommandDef = defineCommand({ table: () => import("./commands/table").then((mod) => mod.default), field: () => import("./commands/field").then((mod) => mod.default), upload: () => import("./commands/upload").then((mod) => mod.default), + "content-translation": () => + import("./commands/content-translation").then((mod) => mod.default), card: () => import("./commands/card").then((mod) => mod.default), dashboard: () => import("./commands/dashboard").then((mod) => mod.default), subscription: () => import("./commands/subscription").then((mod) => mod.default), diff --git a/packages/cli/src/output/views/content-translation.ts b/packages/cli/src/output/views/content-translation.ts new file mode 100644 index 0000000..acb3ba0 --- /dev/null +++ b/packages/cli/src/output/views/content-translation.ts @@ -0,0 +1,8 @@ +import { ContentTranslationUploadResult } from "@metabase/client/domain/content-translation"; + +import type { ResourceView } from "../view"; + +export const contentTranslationUploadView: ResourceView = { + compactPick: ContentTranslationUploadResult, + tableColumns: [{ key: "success", label: "Success" }], +}; diff --git a/packages/cli/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts index 4faecb4..aac33b4 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -323,6 +323,8 @@ const ALL_COMMANDS = [ "upload csv", "upload append", "upload replace", + "content-translation download", + "content-translation upload", "card list", "card get", "card query", @@ -444,6 +446,10 @@ const ALL_COMMANDS = [ ]; const MEASURE_CAPABILITIES = { minVersion: 59 } as const; +const CONTENT_TRANSLATION_CAPABILITIES = { + minVersion: 58, + tokenFeature: "content_translation", +} as const; const TRANSFORM_CAPABILITIES = { minVersion: 59 } as const; const TRANSFORM_JOB_SET_ACTIVE_CAPABILITIES = { minVersion: 61 } as const; @@ -506,6 +512,19 @@ describe("command tree contract", () => { expect(cardList?.capabilities).toEqual(BASELINE_CAPABILITIES); }); + it("gates every content translation command on its premium feature", async () => { + const entries = await allEntries(); + const capabilities = Object.fromEntries( + entries + .filter((entry) => entry.command.startsWith("content-translation ")) + .map((entry) => [entry.command, entry.capabilities]), + ); + expect(capabilities).toEqual({ + "content-translation download": CONTENT_TRANSLATION_CAPABILITIES, + "content-translation upload": CONTENT_TRANSLATION_CAPABILITIES, + }); + }); + it("carries the transform version gates through to every transform command", async () => { const entries = await allEntries(); const transformCapabilities = Object.fromEntries( diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 6301dac..4ad45d8 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,6 +1,7 @@ import { type ClientCredentials, type ClientOptions, createTransport } from "./http/transport"; import { cardResource } from "./resources/card"; import { collectionResource } from "./resources/collection"; +import { contentTranslationResource } from "./resources/content-translation"; import { dashboardResource } from "./resources/dashboard"; import { databaseResource } from "./resources/database"; import { datasetResource } from "./resources/dataset"; @@ -34,6 +35,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions) return { card: cardResource(transport), collection: collectionResource(transport), + contentTranslation: contentTranslationResource(transport), dashboard: dashboardResource(transport), database: databaseResource(transport), dataset: datasetResource(transport), diff --git a/packages/client/src/domain/content-translation.ts b/packages/client/src/domain/content-translation.ts new file mode 100644 index 0000000..a837645 --- /dev/null +++ b/packages/client/src/domain/content-translation.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const ContentTranslationUploadResult = z.object({ + success: z.literal(true), +}); +export type ContentTranslationUploadResult = z.infer; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3ea5b8b..356dcb0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -102,6 +102,7 @@ export { CollectionTreeNode, CollectionUpdateInput, } from "./domain/collection"; +export { ContentTranslationUploadResult } from "./domain/content-translation"; export { CronUiDisplayType } from "./domain/cron"; export { Dashboard, diff --git a/packages/client/src/resources/content-translation.test.ts b/packages/client/src/resources/content-translation.test.ts new file mode 100644 index 0000000..8b59bac --- /dev/null +++ b/packages/client/src/resources/content-translation.test.ts @@ -0,0 +1,109 @@ +import { assert, describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { ResponseShapeError } from "../errors"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +import type { CsvFile } from "./csv-upload"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const CSV_TEXT = "Language,String,Translation\nsv,Title,Rubrik\nar,Cat,قطة\n"; + +const CSV_FILE: CsvFile = { + filename: "translations.csv", + bytes: new TextEncoder().encode(CSV_TEXT), +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +async function thrownBy(run: () => Promise): Promise { + try { + await run(); + } catch (error: unknown) { + return error; + } + throw new Error("expected the call to reject"); +} + +describe("content-translation resource wire requests", () => { + it("downloads the dictionary as an unparsed byte stream", async () => { + const { mb, capture } = clientOver([ + new Response(CSV_TEXT, { headers: { "content-type": "text/csv; charset=utf-8" } }), + ]); + + const stream = await mb.contentTranslation.download(); + + expect(await new Response(stream).text()).toBe(CSV_TEXT); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/content-translation/csv", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("uploads the complete CSV as multipart form data", async () => { + const { mb, capture } = clientOver([jsonResponse({ success: true })]); + + await mb.contentTranslation.upload(CSV_FILE); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/content-translation/upload-dictionary", + method: "POST", + headers: JSON_READ_HEADERS, + body: { + parts: [ + { + name: "file", + value: CSV_TEXT, + filename: "translations.csv", + contentType: "text/csv", + }, + ], + }, + }, + ]); + }); + + it("returns the parsed upload confirmation", async () => { + const { mb } = clientOver([jsonResponse({ success: true })]); + + await expect(mb.contentTranslation.upload(CSV_FILE)).resolves.toEqual({ success: true }); + }); + + it("rejects an upload response that does not confirm success", async () => { + const { mb } = clientOver([jsonResponse({ success: false })]); + + const error = await thrownBy(() => mb.contentTranslation.upload(CSV_FILE)); + + assert(error instanceof ResponseShapeError, "expected a ResponseShapeError"); + expect(error.message).toContain("success: Invalid input: expected true"); + }); +}); diff --git a/packages/client/src/resources/content-translation.ts b/packages/client/src/resources/content-translation.ts new file mode 100644 index 0000000..1c81746 --- /dev/null +++ b/packages/client/src/resources/content-translation.ts @@ -0,0 +1,32 @@ +import { ContentTranslationUploadResult } from "../domain/content-translation"; +import type { RequestOptions, Transport } from "../http/transport"; + +import { buildCsvFormData, type CsvFile } from "./csv-upload"; + +const CONTENT_TRANSLATION_PATH = "/api/ee/content-translation"; + +export function contentTranslationResource(transport: Transport) { + /** Download the complete content translation dictionary as CSV. */ + async function download(options: RequestOptions = {}): Promise> { + return transport.requestStream(`${CONTENT_TRANSLATION_PATH}/csv`, options); + } + + /** Replace the complete content translation dictionary with the attached CSV. */ + async function upload( + file: CsvFile, + options: RequestOptions = {}, + ): Promise { + const form = buildCsvFormData(file); + return transport.requestParsed( + ContentTranslationUploadResult, + `${CONTENT_TRANSLATION_PATH}/upload-dictionary`, + { + ...options, + method: "POST", + body: form, + }, + ); + } + + return { download, upload }; +} diff --git a/tests/e2e/content-translation.e2e.test.ts b/tests/e2e/content-translation.e2e.test.ts new file mode 100644 index 0000000..f79140a --- /dev/null +++ b/tests/e2e/content-translation.e2e.test.ts @@ -0,0 +1,129 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { ContentTranslationUploadResult } from "@metabase/client/domain/content-translation"; +import { parseJson } from "@metabase/client/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { requireServer } from "./server-gate"; + +const CSV_CONTENT = "Language,String,Translation\nsv,Title,Rubrik\nar,Cat,قطة\n"; + +const skipReason = requireServer( + "content-translation › content translation e2e against EE endpoints", + { + minVersion: 58, + tokenFeature: "content_translation", + }, +); + +describe("content-translation arg validation e2e (no Metabase contact required)", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + it("upload without a file fails fast with ConfigError before any request", async () => { + const result = await runCli({ + args: ["content-translation", "upload", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe( + "provide the CSV file to upload with --file ", + ); + expect(result.stdout).toBe(""); + }); + + it("upload with a missing file path fails fast with ConfigError", async () => { + const path = join(tmpdir(), "mb-content-translation-does-not-exist.csv"); + const result = await runCli({ + args: ["content-translation", "upload", "--file", path, "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe(`--file not found: ${path}`); + expect(result.stdout).toBe(""); + }); +}); + +describe.skipIf(skipReason !== null)("content translation e2e against EE endpoints", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + async function tempCsv(): Promise { + const dir = await mkdtemp(join(tmpdir(), "mb-content-translation-e2e-")); + tempDirs.push(dir); + const path = join(dir, "translations.csv"); + await writeFile(path, CSV_CONTENT); + return path; + } + + function authEnv(): Record { + return { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }; + } + + it("download streams the server dictionary as CSV", async () => { + const result = await runCli({ + args: ["content-translation", "download"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Locale Code,String,Translation"); + expect(result.stderr).toBe(""); + }); + + it("upload replaces the dictionary and reports the server confirmation", async () => { + const configHome = await makeIsolatedConfigHome(); + const upload = await runCli({ + args: ["content-translation", "upload", "--file", await tempCsv(), "--json"], + configHome, + env: authEnv(), + }); + + expect(upload.exitCode, upload.stderr).toBe(0); + expect(parseJson(upload.stdout, ContentTranslationUploadResult)).toEqual({ success: true }); + + const download = await runCli({ + args: ["content-translation", "download"], + configHome, + env: authEnv(), + }); + expect(download.exitCode, download.stderr).toBe(0); + expect(download.stdout).toContain("sv,Title,Rubrik"); + expect(download.stdout).toContain("ar,Cat,قطة"); + }); +});