@smartcat/sanity-plugin 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +174 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +338 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +175 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +340 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2172 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2179 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +291 -0
  39. package/dist/locjson.d.ts +291 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +277 -0
  45. package/dist/progress.d.ts +277 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +274 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +34 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +615 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +157 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +31 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +54 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +409 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +290 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +268 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +46 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +1004 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +86 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +698 -0
  118. package/src/tool/processing.ts +839 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,346 @@
1
+ import {describe, expect, it, vi} from 'vitest'
2
+ import {runImportDownload, type SanityLikeClient, type SmartcatImportClient} from './index'
3
+
4
+ function fakeSanity(project: unknown) {
5
+ const sets: Record<string, unknown>[] = []
6
+ const client: SanityLikeClient = {
7
+ fetch: vi.fn(async () => project) as never,
8
+ patch: () => {
9
+ const attrs: Record<string, unknown> = {}
10
+ const p = {
11
+ set: (a: Record<string, unknown>) => {
12
+ Object.assign(attrs, a)
13
+ return p
14
+ },
15
+ commit: async () => {
16
+ sets.push(attrs)
17
+ return {}
18
+ },
19
+ }
20
+ return p
21
+ },
22
+ }
23
+ return {client, sets}
24
+ }
25
+
26
+ // One source doc ("doc1abcd…"): fr fully confirmed, de partially confirmed,
27
+ // es with nothing confirmed yet.
28
+ const PROJECT = {
29
+ _id: 'p1',
30
+ smartcatProjectId: 'sc-1',
31
+ items: [{doc: {_id: 'doc1abcd-eeee', title: 'About'}}],
32
+ progress: [],
33
+ }
34
+ const SC_PROJECT = {
35
+ id: 'sc-1',
36
+ workflowStages: [{id: 'st-tr', stageType: 'translation'}, {id: 'st-ed', stageType: 'editing'}],
37
+ documents: [
38
+ {id: 'g_12', name: 'About-doc1abcd', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}, {id: 'st-ed', progress: 100}]},
39
+ {id: 'g_10', name: 'About-doc1abcd', targetLanguage: 'de', workflowStages: [{id: 'st-tr', progress: 100}, {id: 'st-ed', progress: 0}]},
40
+ {id: 'g_8', name: 'About-doc1abcd', targetLanguage: 'es', workflowStages: [{id: 'st-tr', progress: 0}, {id: 'st-ed', progress: 0}]},
41
+ ],
42
+ }
43
+
44
+ describe('runImportDownload', () => {
45
+ it('downloads targets with confirmed content (any stage) and skips the unconfirmed', async () => {
46
+ const {client: sanity, sets} = fakeSanity(PROJECT)
47
+ const smartcat: SmartcatImportClient = {
48
+ getProject: async () => SC_PROJECT,
49
+ exportDocument: vi.fn(async (id: string) => `{"id":"${id}"}`),
50
+ }
51
+
52
+ const result = await runImportDownload({sanity, smartcat, projectId: 'p1'})
53
+ // fr (complete) + de (partially confirmed) downloaded; es (nothing confirmed) skipped.
54
+ expect(result).toEqual({downloaded: 2, skipped: 1, remaining: 0, alreadyImported: 0})
55
+
56
+ const set = sets.at(-1)!
57
+ expect(set.status).toBe('downloaded')
58
+ const inbox = set.inbox as {targetLanguage: string; sourceDocId: string; locjson: string}[]
59
+ expect(inbox.map((i) => i.targetLanguage).sort()).toEqual(['de', 'fr'])
60
+ // Progress is refreshed for every target (sorted by language: de, es, fr).
61
+ const progress = set.progress as {targets: {language: string; complete: boolean}[]}[]
62
+ expect(progress[0].targets.map((t) => `${t.language}:${t.complete}`)).toEqual(['de:false', 'es:false', 'fr:true'])
63
+ })
64
+
65
+ it('uses one batched export per language and correlates files by x-sanity metadata', async () => {
66
+ // Two source documents, both complete in fr — one batch of two.
67
+ const project = {
68
+ _id: 'p1',
69
+ smartcatProjectId: 'sc-1',
70
+ items: [
71
+ {doc: {_id: 'doc1abcd-eeee', title: 'About'}},
72
+ {doc: {_id: 'doc2wxyz-ffff', title: 'Home'}},
73
+ ],
74
+ progress: [],
75
+ }
76
+ const scProject = {
77
+ id: 'sc-1',
78
+ workflowStages: [{id: 'st-tr', stageType: 'translation'}],
79
+ documents: [
80
+ {id: 'g_12', name: 'About-doc1abcd', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
81
+ {id: 'h_12', name: 'Home-doc2wxyz', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
82
+ ],
83
+ }
84
+ const locjsonFor = (docId: string) =>
85
+ JSON.stringify({properties: {'x-sanity': {documentId: docId}}, units: []})
86
+
87
+ const {client: sanity, sets} = fakeSanity(project)
88
+ const exportDocument = vi.fn(async () => '{}')
89
+ const exportDocumentsBatch = vi.fn(async () => [
90
+ // Archive order differs from request order on purpose.
91
+ {filename: 'b.locjson', content: locjsonFor('doc2wxyz-ffff')},
92
+ {filename: 'a.locjson', content: locjsonFor('doc1abcd-eeee')},
93
+ ])
94
+ const smartcat: SmartcatImportClient = {getProject: async () => scProject, exportDocument, exportDocumentsBatch}
95
+
96
+ const lines: string[] = []
97
+ const result = await runImportDownload({sanity, smartcat, projectId: 'p1', onLog: (l) => lines.push(`${l.level}|${l.message}`)})
98
+ expect(result).toEqual({downloaded: 2, skipped: 0, remaining: 0, alreadyImported: 0})
99
+ expect(exportDocumentsBatch).toHaveBeenCalledTimes(1)
100
+ expect(exportDocumentsBatch).toHaveBeenCalledWith(['g_12', 'h_12'])
101
+ expect(exportDocument).not.toHaveBeenCalled()
102
+
103
+ // The run narrates its batching so a degradation can never be silent.
104
+ expect(lines.some((l) => l.includes('Downloading 2 target(s) in 1 batch(es) of up to 50, 1 in parallel'))).toBe(true)
105
+ expect(lines.some((l) => l.includes('Batch 1/1 (fr): 2 doc(s) in'))).toBe(true)
106
+ expect(lines.some((l) => l.includes('Download phase:') && l.includes('0 failed, 0 per-document fallback'))).toBe(true)
107
+
108
+ const inbox = (sets.at(-1)!.inbox as {sourceDocId: string; locjson: string}[])
109
+ expect(inbox.find((i) => i.sourceDocId === 'doc1abcd-eeee')?.locjson).toBe(locjsonFor('doc1abcd-eeee'))
110
+ expect(inbox.find((i) => i.sourceDocId === 'doc2wxyz-ffff')?.locjson).toBe(locjsonFor('doc2wxyz-ffff'))
111
+ })
112
+
113
+ it('falls back to per-document export when the batch call fails or misses a file', async () => {
114
+ const project = {
115
+ _id: 'p1',
116
+ smartcatProjectId: 'sc-1',
117
+ items: [
118
+ {doc: {_id: 'doc1abcd-eeee', title: 'About'}},
119
+ {doc: {_id: 'doc2wxyz-ffff', title: 'Home'}},
120
+ ],
121
+ progress: [],
122
+ }
123
+ const scProject = {
124
+ id: 'sc-1',
125
+ workflowStages: [{id: 'st-tr', stageType: 'translation'}],
126
+ documents: [
127
+ {id: 'g_12', name: 'About-doc1abcd', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
128
+ {id: 'h_12', name: 'Home-doc2wxyz', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
129
+ ],
130
+ }
131
+ const {client: sanity} = fakeSanity(project)
132
+
133
+ // Whole batch fails → both documents fall back to single exports.
134
+ const failing: SmartcatImportClient = {
135
+ getProject: async () => scProject,
136
+ exportDocument: vi.fn(async (id: string) => `{"id":"${id}"}`),
137
+ exportDocumentsBatch: vi.fn(async () => {
138
+ throw new Error('500 from Smartcat')
139
+ }),
140
+ }
141
+ const failed = await runImportDownload({sanity, smartcat: failing, projectId: 'p1'})
142
+ expect(failed.downloaded).toBe(2)
143
+ expect(failing.exportDocument).toHaveBeenCalledTimes(2)
144
+
145
+ // Batch succeeds but covers only one document → the other falls back.
146
+ const partial: SmartcatImportClient = {
147
+ getProject: async () => scProject,
148
+ exportDocument: vi.fn(async (id: string) => `{"id":"${id}"}`),
149
+ exportDocumentsBatch: vi.fn(async () => [
150
+ {filename: 'a.locjson', content: JSON.stringify({properties: {'x-sanity': {documentId: 'doc1abcd-eeee'}}})},
151
+ ]),
152
+ }
153
+ const partialLines: string[] = []
154
+ const recovered = await runImportDownload({sanity, smartcat: partial, projectId: 'p1', onLog: (l) => partialLines.push(l.message)})
155
+ expect(recovered.downloaded).toBe(2)
156
+ expect(partial.exportDocument).toHaveBeenCalledTimes(1)
157
+ expect(partial.exportDocument).toHaveBeenCalledWith('h_12')
158
+ // Partial archive coverage is called out, not silently patched over.
159
+ expect(partialLines.some((m) => m.includes('1 of 2 file(s) missing from the batch archive'))).toBe(true)
160
+ expect(partialLines.some((m) => m.includes('1 per-document fallback download(s)'))).toBe(true)
161
+ })
162
+
163
+ it('skips already-imported targets and defers overflow beyond the inbox byte cap', async () => {
164
+ const bigLocjson = JSON.stringify({properties: {'x-sanity': {documentId: 'doc1abcd-eeee'}}, pad: 'x'.repeat(600)})
165
+ const project = {
166
+ _id: 'p1',
167
+ smartcatProjectId: 'sc-1',
168
+ items: [
169
+ {doc: {_id: 'doc1abcd-eeee', title: 'About'}},
170
+ {doc: {_id: 'doc2wxyz-ffff', title: 'Home'}},
171
+ {doc: {_id: 'doc3qrst-gggg', title: 'FAQ'}},
172
+ ],
173
+ // doc3 was already imported in a previous run.
174
+ progress: [
175
+ {
176
+ _key: 'doc3qrst-gggg__doc',
177
+ sourceDocId: 'doc3qrst-gggg',
178
+ targets: [{_key: 'k3', language: 'fr', smartcatDocumentId: 'i_12', stages: [], complete: true, imported: true}],
179
+ },
180
+ ],
181
+ }
182
+ const scProject = {
183
+ id: 'sc-1',
184
+ workflowStages: [{id: 'st-tr', stageType: 'translation'}],
185
+ documents: [
186
+ {id: 'g_12', name: 'About-doc1abcd', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
187
+ {id: 'h_12', name: 'Home-doc2wxyz', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
188
+ {id: 'i_12', name: 'FAQ-doc3qrst', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
189
+ ],
190
+ }
191
+ const {client: sanity, sets} = fakeSanity(project)
192
+ const smartcat: SmartcatImportClient = {
193
+ getProject: async () => scProject,
194
+ exportDocument: vi.fn(async () => bigLocjson),
195
+ }
196
+
197
+ // batchSize 1 → one download per batch; the first fills the tiny cap, the
198
+ // second is deferred; the already-imported third is skipped outright.
199
+ // concurrency 1 makes the soft cap deterministic (parallel workers may
200
+ // claim a batch before an in-flight download's bytes are counted).
201
+ const result = await runImportDownload({sanity, smartcat, projectId: 'p1', batchSize: 1, maxInboxBytes: 100, concurrency: 1})
202
+ expect(result).toEqual({downloaded: 1, skipped: 0, remaining: 1, alreadyImported: 1})
203
+ expect((sets.at(-1)!.inbox as unknown[]).length).toBe(1)
204
+ expect(sets.at(-1)!.status).toBe('downloaded')
205
+ expect(sets.at(-1)!.importRemaining).toBe(1)
206
+ })
207
+
208
+ it('runs up to 4 batch downloads in parallel', async () => {
209
+ const DOCS = 8
210
+ const items = Array.from({length: DOCS}, (_, i) => ({doc: {_id: `d${i}00000-aaaa`, title: `Doc ${i}`}}))
211
+ const documents = items.map(({doc}, i) => ({
212
+ id: `f${i}_1`,
213
+ name: `Doc-${doc._id.slice(0, 8)}`,
214
+ targetLanguage: 'fr',
215
+ workflowStages: [{id: 'st-tr', progress: 100}],
216
+ }))
217
+ const {client: sanity} = fakeSanity({_id: 'p1', smartcatProjectId: 'sc-1', items, progress: []})
218
+
219
+ let inFlight = 0
220
+ let maxInFlight = 0
221
+ const smartcat: SmartcatImportClient = {
222
+ getProject: async () => ({id: 'sc-1', workflowStages: [{id: 'st-tr', stageType: 'translation'}], documents}),
223
+ // batchSize 1 → every batch degrades to a single export; measure overlap.
224
+ exportDocument: vi.fn(async () => {
225
+ inFlight++
226
+ maxInFlight = Math.max(maxInFlight, inFlight)
227
+ await new Promise((r) => setTimeout(r, 5))
228
+ inFlight--
229
+ return '{}'
230
+ }),
231
+ }
232
+
233
+ const result = await runImportDownload({sanity, smartcat, projectId: 'p1', batchSize: 1})
234
+ expect(result.downloaded).toBe(8)
235
+ expect(maxInFlight).toBeGreaterThanOrEqual(2) // genuinely parallel…
236
+ expect(maxInFlight).toBeLessThanOrEqual(4) // …but never beyond the pool size
237
+ })
238
+
239
+ it('defers batches once the time budget is exhausted instead of overrunning the Function timeout', async () => {
240
+ const items = [
241
+ {doc: {_id: 'doc1abcd-eeee', title: 'About'}},
242
+ {doc: {_id: 'doc2wxyz-ffff', title: 'Home'}},
243
+ ]
244
+ const documents = [
245
+ {id: 'g_12', name: 'About-doc1abcd', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
246
+ {id: 'h_12', name: 'Home-doc2wxyz', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
247
+ ]
248
+ const {client: sanity, sets} = fakeSanity({_id: 'p1', smartcatProjectId: 'sc-1', items, progress: []})
249
+ const smartcat: SmartcatImportClient = {
250
+ getProject: async () => ({id: 'sc-1', workflowStages: [{id: 'st-tr', stageType: 'translation'}], documents}),
251
+ exportDocument: vi.fn(async () => '{}'),
252
+ }
253
+
254
+ // Clock: budget of 100ms; the first batch is claimed at t=0, the second at t=200.
255
+ const ticks = [0, 0, 200, 200, 200]
256
+ const nowMs = () => ticks.shift() ?? 200
257
+ const result = await runImportDownload({
258
+ sanity, smartcat, projectId: 'p1', batchSize: 1, concurrency: 1, timeBudgetMs: 100, nowMs,
259
+ })
260
+ expect(result).toEqual({downloaded: 1, skipped: 0, remaining: 1, alreadyImported: 0})
261
+ // The run still exits cleanly: status "downloaded" + the deferred count for auto-continue.
262
+ expect(sets.at(-1)!.status).toBe('downloaded')
263
+ expect(sets.at(-1)!.importRemaining).toBe(1)
264
+ })
265
+
266
+ it('handles a Huel-sized project (224 docs × 10 languages = 2,240 targets) in ~50 batched calls', async () => {
267
+ const LANGS = ['de', 'fr', 'es', 'it', 'da', 'fi', 'nl', 'pl', 'pt', 'sv']
268
+ const DOCS = 224
269
+ const itemId = (i: number) => `i${String(i).padStart(7, '0')}-aaaa`
270
+
271
+ const items = Array.from({length: DOCS}, (_, i) => ({doc: {_id: itemId(i), title: `Doc ${i}`}}))
272
+ const documents = items.flatMap(({doc}, i) =>
273
+ LANGS.map((lang, l) => ({
274
+ id: `f${i}_${l}`,
275
+ name: `Doc-${doc._id.slice(0, 8)}`,
276
+ targetLanguage: lang,
277
+ workflowStages: [{id: 'st-tr', progress: 100}],
278
+ })),
279
+ )
280
+ const sourceDocByScId = new Map<string, string>(
281
+ items.flatMap(({doc}, i) => LANGS.map((_, l) => [`f${i}_${l}`, doc._id])),
282
+ )
283
+ const locjsonFor = (scId: string) =>
284
+ JSON.stringify({properties: {'x-sanity': {documentId: sourceDocByScId.get(scId)}}, units: []})
285
+
286
+ let project: Record<string, unknown> = {_id: 'p1', smartcatProjectId: 'sc-1', items, progress: []}
287
+ const sets: Record<string, unknown>[] = []
288
+ const sanity: SanityLikeClient = {
289
+ fetch: (async () => project) as never,
290
+ patch: () => {
291
+ const attrs: Record<string, unknown> = {}
292
+ const p = {
293
+ set: (a: Record<string, unknown>) => (Object.assign(attrs, a), p),
294
+ commit: async () => (sets.push(attrs), {}),
295
+ }
296
+ return p
297
+ },
298
+ }
299
+ const exportDocumentsBatch = vi.fn(async (ids: string[]) => ids.map((id) => ({filename: `${id}.locjson`, content: locjsonFor(id)})))
300
+ const exportDocument = vi.fn(async (id: string) => locjsonFor(id))
301
+ const smartcat: SmartcatImportClient = {
302
+ getProject: async () => ({id: 'sc-1', workflowStages: [{id: 'st-tr', stageType: 'translation'}], documents}),
303
+ exportDocument,
304
+ exportDocumentsBatch,
305
+ }
306
+
307
+ // Payloads are small, so the default inbox cap fits the whole project in one
308
+ // run — and 2,240 targets cost ~50 Smartcat tasks instead of 2,240.
309
+ const result = await runImportDownload({sanity, smartcat, projectId: 'p1'})
310
+ expect(result).toEqual({downloaded: 2240, skipped: 0, remaining: 0, alreadyImported: 0})
311
+ expect(exportDocumentsBatch).toHaveBeenCalledTimes(50) // 10 languages × ceil(224/50)
312
+ expect(exportDocument).not.toHaveBeenCalled()
313
+
314
+ // Capped runs converge: simulate the browser apply (mark downloaded targets
315
+ // imported) between runs and re-import until nothing is left.
316
+ project = {...project, progress: []}
317
+ sets.length = 0
318
+ exportDocumentsBatch.mockClear()
319
+ let imported = 0
320
+ for (let run = 0; run < 20; run++) {
321
+ const r = await runImportDownload({sanity, smartcat, projectId: 'p1', maxInboxBytes: 30_000})
322
+ expect(r.alreadyImported).toBe(imported)
323
+ imported += r.downloaded
324
+ const set = sets.at(-1)!
325
+ const inboxKeys = new Set((set.inbox as {_key: string}[]).map((i) => i._key))
326
+ const progress = (set.progress as {targets: {_key: string; imported: boolean}[]}[]).map((doc) => ({
327
+ ...doc,
328
+ targets: doc.targets.map((t) => (inboxKeys.has(t._key) ? {...t, imported: true} : t)),
329
+ }))
330
+ project = {...project, progress}
331
+ if (r.remaining === 0) break
332
+ expect(r.downloaded).toBeGreaterThan(0) // every run makes forward progress
333
+ }
334
+ expect(imported).toBe(2240)
335
+ })
336
+
337
+ it('errors when the project has no smartcatProjectId', async () => {
338
+ const {client: sanity, sets} = fakeSanity({_id: 'p1'})
339
+ const smartcat: SmartcatImportClient = {
340
+ getProject: async () => ({id: 'x'}),
341
+ exportDocument: async () => '',
342
+ }
343
+ await expect(runImportDownload({sanity, smartcat, projectId: 'p1'})).rejects.toThrow(/smartcatProjectId/)
344
+ expect(sets.at(-1)?.status).toBe('error')
345
+ })
346
+ })