@smartcat/sanity-plugin 1.0.0 → 1.0.1
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.
- package/README.md +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +168 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +311 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +169 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +313 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2110 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +287 -0
- package/dist/locjson.d.ts +287 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +262 -0
- package/dist/progress.d.ts +262 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +258 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +27 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +545 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +98 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +17 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +47 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +387 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +268 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +264 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +44 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +975 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +79 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +623 -0
- package/src/tool/processing.ts +766 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- package/src/types.ts +77 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {runExportUpload, UPLOAD_CHUNK_CONCURRENCY, type SanityLikeClient, type SmartcatExportClient} from './index'
|
|
3
|
+
import {SmartcatError} from '../smartcat/client'
|
|
4
|
+
import type {SmartcatDocument, SmartcatProject} from '../smartcat/types'
|
|
5
|
+
|
|
6
|
+
function fakeSanity(project: unknown) {
|
|
7
|
+
const sets: Record<string, unknown>[] = []
|
|
8
|
+
const unsets: string[][] = []
|
|
9
|
+
const client: SanityLikeClient = {
|
|
10
|
+
fetch: vi.fn(async () => project) as never,
|
|
11
|
+
patch: () => {
|
|
12
|
+
const attrs: Record<string, unknown> = {}
|
|
13
|
+
const p = {
|
|
14
|
+
set: (a: Record<string, unknown>) => {
|
|
15
|
+
Object.assign(attrs, a)
|
|
16
|
+
return p
|
|
17
|
+
},
|
|
18
|
+
unset: (keys: string[]) => {
|
|
19
|
+
unsets.push(keys)
|
|
20
|
+
return p
|
|
21
|
+
},
|
|
22
|
+
commit: async () => {
|
|
23
|
+
sets.push(attrs)
|
|
24
|
+
return {}
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
return p
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
return {client, sets, unsets}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Stateful Smartcat fake. `getProject` reflects documents created so far; names
|
|
34
|
+
// encode the source-id prefix exactly as buildLocjsonFilename does.
|
|
35
|
+
function fakeSmartcat(initialDocs: SmartcatDocument[] = []) {
|
|
36
|
+
const uploads: {filename: string; body: string}[] = []
|
|
37
|
+
const updates: {documentId: string; filename: string; body: string}[] = []
|
|
38
|
+
const deletes: string[] = []
|
|
39
|
+
const docs = [...initialDocs]
|
|
40
|
+
let guid = 0
|
|
41
|
+
const project: SmartcatProject = {
|
|
42
|
+
id: 'guid-1',
|
|
43
|
+
name: 'Spring (1)',
|
|
44
|
+
workflowStages: [{id: 'st-tr', stageType: 'translation'}],
|
|
45
|
+
documents: docs,
|
|
46
|
+
}
|
|
47
|
+
const client: SmartcatExportClient = {
|
|
48
|
+
createProject: vi.fn(async () => ({id: 'guid-1'})),
|
|
49
|
+
uploadDocument: vi.fn(async (_p: string, filename: string, body: string) => {
|
|
50
|
+
uploads.push({filename, body})
|
|
51
|
+
const leaf = filename.split('/').pop()!.replace(/\.[^.]+$/, '')
|
|
52
|
+
const id = `gNew${++guid}_12`
|
|
53
|
+
docs.push({id, name: leaf, targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 0}]})
|
|
54
|
+
return [{id, targetLanguage: 'fr'}]
|
|
55
|
+
}),
|
|
56
|
+
uploadDocuments: vi.fn(async (_p: string, files: {filename: string; content: string}[]) => {
|
|
57
|
+
const out: SmartcatDocument[] = []
|
|
58
|
+
for (const f of files) {
|
|
59
|
+
uploads.push({filename: f.filename, body: f.content})
|
|
60
|
+
const leaf = f.filename.split('/').pop()!.replace(/\.[^.]+$/, '')
|
|
61
|
+
const id = `gNew${++guid}_12`
|
|
62
|
+
const doc = {id, name: leaf, targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 0}]}
|
|
63
|
+
docs.push(doc)
|
|
64
|
+
out.push({id, name: leaf, targetLanguage: 'fr'})
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}),
|
|
68
|
+
updateDocument: vi.fn(async (documentId: string, filename: string, body: string) => {
|
|
69
|
+
updates.push({documentId, filename, body})
|
|
70
|
+
return [{id: documentId}]
|
|
71
|
+
}),
|
|
72
|
+
deleteDocument: vi.fn(async (documentId: string) => {
|
|
73
|
+
deletes.push(documentId)
|
|
74
|
+
// One id removes the whole document (all language entries sharing the guid).
|
|
75
|
+
const guidPart = documentId.slice(0, documentId.lastIndexOf('_'))
|
|
76
|
+
for (let i = docs.length - 1; i >= 0; i--) {
|
|
77
|
+
if (docs[i].id.startsWith(`${guidPart}_`)) docs.splice(i, 1)
|
|
78
|
+
}
|
|
79
|
+
}),
|
|
80
|
+
getProject: vi.fn(async () => project),
|
|
81
|
+
projectUrl: (id) => `https://us.smartcat.com/projects/${id}/general`,
|
|
82
|
+
}
|
|
83
|
+
return {client, uploads, updates, deletes, docs}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ABOUT = {sourceDocId: 'about123-aaaa', title: 'About', filename: 'page/About-about123.locjson', locjson: '{"units":[]}'}
|
|
87
|
+
const CONTACT = {sourceDocId: 'contact1-bbbb', title: 'Contact', filename: 'page/Contact-contact1.locjson', locjson: '{"units":[]}'}
|
|
88
|
+
|
|
89
|
+
describe('runExportUpload — first sync', () => {
|
|
90
|
+
it('creates the project, uploads the outbox, syncs the name back, marks sent', async () => {
|
|
91
|
+
const project = {_id: 'p1', name: 'Spring', sourceLanguage: 'en', targetLanguages: ['fr'], outbox: [ABOUT, CONTACT]}
|
|
92
|
+
const {client: sanity, sets, unsets} = fakeSanity(project)
|
|
93
|
+
const {client: smartcat, uploads, updates} = fakeSmartcat()
|
|
94
|
+
|
|
95
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
96
|
+
|
|
97
|
+
expect(smartcat.createProject).toHaveBeenCalledWith({
|
|
98
|
+
name: 'Spring',
|
|
99
|
+
sourceLanguage: 'en',
|
|
100
|
+
targetLanguages: ['fr'],
|
|
101
|
+
workflowStages: ['translation', 'postediting'],
|
|
102
|
+
})
|
|
103
|
+
expect(uploads.map((u) => u.filename)).toEqual([ABOUT.filename, CONTACT.filename])
|
|
104
|
+
expect(updates).toHaveLength(0)
|
|
105
|
+
expect(result).toEqual({smartcatProjectId: 'guid-1', created: 2, updated: 0, deleted: 0, failed: 0})
|
|
106
|
+
|
|
107
|
+
const set = sets.at(-1)!
|
|
108
|
+
expect(set.status).toBe('sent')
|
|
109
|
+
expect(set.smartcatProjectId).toBe('guid-1')
|
|
110
|
+
expect(set.smartcatProjectUrl).toBe('https://us.smartcat.com/projects/guid-1/general')
|
|
111
|
+
expect(set.name).toBe('Spring (1)') // de-duplicated name mirrored back
|
|
112
|
+
expect(unsets.at(-1)).toEqual(['outbox', 'pendingDeletions'])
|
|
113
|
+
|
|
114
|
+
const progress = set.progress as {sourceDocId: string; targets: {language: string; smartcatDocumentId?: string}[]}[]
|
|
115
|
+
expect(progress.map((p) => p.sourceDocId)).toEqual(['about123-aaaa', 'contact1-bbbb'])
|
|
116
|
+
expect(progress[0].targets[0]).toMatchObject({language: 'fr', smartcatDocumentId: 'gNew1_12'})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('maps Sanity ids to Smartcat codes for createProject', async () => {
|
|
120
|
+
const project = {
|
|
121
|
+
_id: 'p1',
|
|
122
|
+
name: 'Spring',
|
|
123
|
+
sourceLanguage: 'en',
|
|
124
|
+
targetLanguages: ['fr', 'PL-PL'],
|
|
125
|
+
smartcatLanguages: [
|
|
126
|
+
{sanityId: 'en', smartcatLanguage: 'en'},
|
|
127
|
+
{sanityId: 'fr', smartcatLanguage: 'fr'},
|
|
128
|
+
{sanityId: 'PL-PL', smartcatLanguage: 'pl'},
|
|
129
|
+
],
|
|
130
|
+
outbox: [ABOUT],
|
|
131
|
+
}
|
|
132
|
+
const {client: sanity} = fakeSanity(project)
|
|
133
|
+
const {client: smartcat} = fakeSmartcat()
|
|
134
|
+
|
|
135
|
+
await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
136
|
+
|
|
137
|
+
expect(smartcat.createProject).toHaveBeenCalledWith({
|
|
138
|
+
name: 'Spring',
|
|
139
|
+
sourceLanguage: 'en',
|
|
140
|
+
targetLanguages: ['fr', 'pl'], // PL-PL → pl
|
|
141
|
+
workflowStages: ['translation', 'postediting'],
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('passes a chosen template id to createProject (omitting workflow stages)', async () => {
|
|
146
|
+
const project = {
|
|
147
|
+
_id: 'p1',
|
|
148
|
+
name: 'Spring',
|
|
149
|
+
sourceLanguage: 'en',
|
|
150
|
+
targetLanguages: ['fr'],
|
|
151
|
+
workflow: 'template:tmpl-123',
|
|
152
|
+
outbox: [ABOUT],
|
|
153
|
+
}
|
|
154
|
+
const {client: sanity} = fakeSanity(project)
|
|
155
|
+
const {client: smartcat} = fakeSmartcat()
|
|
156
|
+
|
|
157
|
+
await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
158
|
+
|
|
159
|
+
expect(smartcat.createProject).toHaveBeenCalledWith({
|
|
160
|
+
name: 'Spring',
|
|
161
|
+
sourceLanguage: 'en',
|
|
162
|
+
targetLanguages: ['fr'],
|
|
163
|
+
templateId: 'tmpl-123',
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('uses a single translation stage for the AI-only standard workflow', async () => {
|
|
168
|
+
const project = {
|
|
169
|
+
_id: 'p1',
|
|
170
|
+
name: 'Spring',
|
|
171
|
+
sourceLanguage: 'en',
|
|
172
|
+
targetLanguages: ['fr'],
|
|
173
|
+
workflow: 'standard:ai',
|
|
174
|
+
outbox: [ABOUT],
|
|
175
|
+
}
|
|
176
|
+
const {client: sanity} = fakeSanity(project)
|
|
177
|
+
const {client: smartcat} = fakeSmartcat()
|
|
178
|
+
|
|
179
|
+
await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
180
|
+
|
|
181
|
+
expect(smartcat.createProject).toHaveBeenCalledWith({
|
|
182
|
+
name: 'Spring',
|
|
183
|
+
sourceLanguage: 'en',
|
|
184
|
+
targetLanguages: ['fr'],
|
|
185
|
+
workflowStages: ['translation'],
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('persists the new project id before uploading, so a failed run does not duplicate it', async () => {
|
|
190
|
+
const project = {_id: 'p1', name: 'Spring', sourceLanguage: 'en', targetLanguages: ['fr'], outbox: [ABOUT]}
|
|
191
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
192
|
+
const {client: smartcat} = fakeSmartcat()
|
|
193
|
+
// Make the only upload fail after the project has been created — both batch
|
|
194
|
+
// attempts and the per-file fallback all reject.
|
|
195
|
+
;(smartcat.uploadDocuments as ReturnType<typeof vi.fn>)
|
|
196
|
+
.mockRejectedValueOnce(new Error('network blip'))
|
|
197
|
+
.mockRejectedValueOnce(new Error('network blip'))
|
|
198
|
+
;(smartcat.uploadDocument as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('network blip'))
|
|
199
|
+
|
|
200
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T', sleep: async () => {}})
|
|
201
|
+
|
|
202
|
+
// The project id was committed before the upload that failed, so a re-send reuses it.
|
|
203
|
+
expect(smartcat.createProject).toHaveBeenCalledTimes(1)
|
|
204
|
+
expect(sets.some((s) => s.smartcatProjectId === 'guid-1')).toBe(true)
|
|
205
|
+
// A per-document write failure is recorded but does NOT fail the whole project.
|
|
206
|
+
expect(result).toMatchObject({created: 0, failed: 1})
|
|
207
|
+
expect(sets.at(-1)?.status).toBe('sent')
|
|
208
|
+
expect(sets.at(-1)?.lastError).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('reuses an existing Smartcat project instead of creating a second one on re-run', async () => {
|
|
212
|
+
// Same project, but now it already carries a smartcatProjectId (persisted by a prior run).
|
|
213
|
+
const project = {
|
|
214
|
+
_id: 'p1',
|
|
215
|
+
name: 'Spring',
|
|
216
|
+
sourceLanguage: 'en',
|
|
217
|
+
targetLanguages: ['fr'],
|
|
218
|
+
smartcatProjectId: 'guid-1',
|
|
219
|
+
outbox: [ABOUT],
|
|
220
|
+
}
|
|
221
|
+
const {client: sanity} = fakeSanity(project)
|
|
222
|
+
const {client: smartcat} = fakeSmartcat()
|
|
223
|
+
|
|
224
|
+
await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
225
|
+
|
|
226
|
+
expect(smartcat.createProject).not.toHaveBeenCalled()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('settles to draft (not error) when there is nothing to sync', async () => {
|
|
230
|
+
const {client: sanity, sets} = fakeSanity({_id: 'p1', targetLanguages: ['fr'], outbox: []})
|
|
231
|
+
const {client: smartcat} = fakeSmartcat()
|
|
232
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1'})
|
|
233
|
+
expect(result).toMatchObject({created: 0, updated: 0, deleted: 0})
|
|
234
|
+
expect(smartcat.createProject).not.toHaveBeenCalled()
|
|
235
|
+
expect(sets.at(-1)?.status).toBe('draft')
|
|
236
|
+
expect(sets.at(-1)?.lastError).toBeNull()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('keeps an already-linked project at "sent" when there is nothing to sync', async () => {
|
|
240
|
+
const {client: sanity, sets} = fakeSanity({
|
|
241
|
+
_id: 'p1',
|
|
242
|
+
targetLanguages: ['fr'],
|
|
243
|
+
outbox: [],
|
|
244
|
+
smartcatProjectId: 'guid-1',
|
|
245
|
+
})
|
|
246
|
+
const {client: smartcat} = fakeSmartcat()
|
|
247
|
+
await runExportUpload({sanity, smartcat, projectId: 'p1'})
|
|
248
|
+
expect(sets.at(-1)?.status).toBe('sent')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('captures a freshly-uploaded doc (and its title) even when getProject lags on indexing', async () => {
|
|
252
|
+
// The upload response carries the new doc, but getProject hasn't indexed it
|
|
253
|
+
// yet — the title (outbox-only) must still land in progress this run.
|
|
254
|
+
const project = {_id: 'p1', name: 'Spring', sourceLanguage: 'en', targetLanguages: ['fr'], outbox: [ABOUT]}
|
|
255
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
256
|
+
const smartcat: SmartcatExportClient = {
|
|
257
|
+
createProject: vi.fn(async () => ({id: 'guid-1'})),
|
|
258
|
+
uploadDocument: vi.fn(async () => [{id: 'gNew1_12', targetLanguage: 'fr'}]),
|
|
259
|
+
// Batch response carries the created doc's own name — set-wise correlation
|
|
260
|
+
// captures the title from it while getProject reports no documents yet.
|
|
261
|
+
uploadDocuments: vi.fn(async () => [{id: 'gNew1_12', name: 'About-about123', targetLanguage: 'fr'}]),
|
|
262
|
+
updateDocument: vi.fn(async () => []),
|
|
263
|
+
deleteDocument: vi.fn(async () => {}),
|
|
264
|
+
// …getProject still reports no documents (indexing lag).
|
|
265
|
+
getProject: vi.fn(async () => ({id: 'guid-1', name: 'Spring', workflowStages: [], documents: []})),
|
|
266
|
+
projectUrl: (id) => `https://us.smartcat.com/projects/${id}/general`,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
270
|
+
|
|
271
|
+
const set = sets.at(-1)!
|
|
272
|
+
const progress = set.progress as {sourceDocId: string; title?: string; targets: {language: string; smartcatDocumentId?: string}[]}[]
|
|
273
|
+
expect(progress).toHaveLength(1)
|
|
274
|
+
expect(progress[0]).toMatchObject({sourceDocId: 'about123-aaaa', title: 'About'})
|
|
275
|
+
expect(progress[0].targets[0]).toMatchObject({language: 'fr', smartcatDocumentId: 'gNew1_12'})
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('runExportUpload — re-sync (existing project)', () => {
|
|
280
|
+
it('reuses the project and updates existing docs in place, without re-creating', async () => {
|
|
281
|
+
const project = {
|
|
282
|
+
_id: 'p1',
|
|
283
|
+
name: 'Spring (1)',
|
|
284
|
+
sourceLanguage: 'en',
|
|
285
|
+
targetLanguages: ['fr'],
|
|
286
|
+
smartcatProjectId: 'guid-1',
|
|
287
|
+
smartcatProjectUrl: 'https://us.smartcat.com/projects/guid-1/general',
|
|
288
|
+
outbox: [ABOUT, CONTACT],
|
|
289
|
+
progress: [],
|
|
290
|
+
}
|
|
291
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
292
|
+
// Both source files already exist in Smartcat.
|
|
293
|
+
const {client: smartcat, uploads, updates} = fakeSmartcat([
|
|
294
|
+
{id: 'gA_12', name: 'About-about123', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 50}]},
|
|
295
|
+
{id: 'gC_12', name: 'Contact-contact1', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}]},
|
|
296
|
+
])
|
|
297
|
+
|
|
298
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
299
|
+
|
|
300
|
+
expect(smartcat.createProject).not.toHaveBeenCalled()
|
|
301
|
+
expect(uploads).toHaveLength(0)
|
|
302
|
+
expect(updates.map((u) => u.documentId).sort()).toEqual(['gA_12', 'gC_12'])
|
|
303
|
+
expect(result).toEqual({smartcatProjectId: 'guid-1', created: 0, updated: 2, deleted: 0, failed: 0})
|
|
304
|
+
|
|
305
|
+
const set = sets.at(-1)!
|
|
306
|
+
expect(set.status).toBe('sent')
|
|
307
|
+
expect(set.name).toBe('Spring (1)') // Smartcat name mirrored back on every sync
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('creates only the newly-added item when re-syncing', async () => {
|
|
311
|
+
const project = {
|
|
312
|
+
_id: 'p1',
|
|
313
|
+
sourceLanguage: 'en',
|
|
314
|
+
targetLanguages: ['fr'],
|
|
315
|
+
smartcatProjectId: 'guid-1',
|
|
316
|
+
outbox: [ABOUT, CONTACT],
|
|
317
|
+
progress: [],
|
|
318
|
+
}
|
|
319
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
320
|
+
// Only "About" exists yet; "Contact" is new.
|
|
321
|
+
const {client: smartcat, uploads, updates} = fakeSmartcat([
|
|
322
|
+
{id: 'gA_12', name: 'About-about123', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 10}]},
|
|
323
|
+
])
|
|
324
|
+
|
|
325
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
326
|
+
|
|
327
|
+
expect(updates.map((u) => u.documentId)).toEqual(['gA_12'])
|
|
328
|
+
expect(uploads.map((u) => u.filename)).toEqual([CONTACT.filename])
|
|
329
|
+
expect(result).toEqual({smartcatProjectId: 'guid-1', created: 1, updated: 1, deleted: 0, failed: 0})
|
|
330
|
+
expect(sets.at(-1)!.status).toBe('sent')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('keeps syncing other docs and stays "sent" when one update fails (e.g. document lock)', async () => {
|
|
334
|
+
const project = {
|
|
335
|
+
_id: 'p1',
|
|
336
|
+
sourceLanguage: 'en',
|
|
337
|
+
targetLanguages: ['fr'],
|
|
338
|
+
smartcatProjectId: 'guid-1',
|
|
339
|
+
outbox: [ABOUT, CONTACT],
|
|
340
|
+
progress: [],
|
|
341
|
+
}
|
|
342
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
343
|
+
// Both already exist → both go through updateDocument; "About" fails with a 400.
|
|
344
|
+
const {client: smartcat, updates} = fakeSmartcat([
|
|
345
|
+
{id: 'gA_12', name: 'About-about123', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 10}]},
|
|
346
|
+
{id: 'gC_12', name: 'Contact-contact1', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 10}]},
|
|
347
|
+
])
|
|
348
|
+
smartcat.updateDocument = vi.fn(async (documentId: string, filename: string, body: string) => {
|
|
349
|
+
if (documentId === 'gA_12') {
|
|
350
|
+
throw new SmartcatError(400, 'Error processing request. Correlation id: abc-123, Trace id: def Invalid input')
|
|
351
|
+
}
|
|
352
|
+
updates.push({documentId, filename, body})
|
|
353
|
+
return [{id: documentId}]
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
357
|
+
|
|
358
|
+
// The good one still synced; the project is NOT marked errored.
|
|
359
|
+
expect(updates.map((u) => u.documentId)).toEqual(['gC_12'])
|
|
360
|
+
expect(result).toMatchObject({updated: 1, failed: 1})
|
|
361
|
+
const set = sets.at(-1)!
|
|
362
|
+
expect(set.status).toBe('sent')
|
|
363
|
+
expect(set.lastError).toBeNull()
|
|
364
|
+
// The failure is visible in the persisted log.
|
|
365
|
+
const log = JSON.parse(set.functionLog as string) as {level: string; message: string}[]
|
|
366
|
+
expect(log.some((l) => l.level === 'error' && /Skipped .*About/.test(l.message))).toBe(true)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('logs every Smartcat HTTP call via the request logger, with bodies on failures', async () => {
|
|
370
|
+
const project = {_id: 'p1', name: 'Spring', sourceLanguage: 'en', targetLanguages: ['fr'], outbox: [ABOUT]}
|
|
371
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
372
|
+
const {client: smartcat} = fakeSmartcat()
|
|
373
|
+
// Drive the real request-logger path: the fake forwards calls to the logger.
|
|
374
|
+
let logger: ((info: {method: string; path: string; status: number; body: string}) => void) | undefined
|
|
375
|
+
smartcat.setRequestLogger = (fn) => {
|
|
376
|
+
logger = fn
|
|
377
|
+
}
|
|
378
|
+
// ABOUT is a create (no existing Smartcat doc), so it goes through the batch
|
|
379
|
+
// upload path — override that method to drive the logger.
|
|
380
|
+
smartcat.uploadDocuments = vi.fn(async () => {
|
|
381
|
+
logger?.({method: 'POST', path: '/api/integration/v1/project/document', status: 200, body: '[]'})
|
|
382
|
+
return [{id: 'gNew_12', targetLanguage: 'fr'}]
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
386
|
+
|
|
387
|
+
const log = JSON.parse(sets.at(-1)!.functionLog as string) as {level: string; message: string}[]
|
|
388
|
+
expect(log.some((l) => l.level === 'info' && l.message === 'POST /api/integration/v1/project/document → 200')).toBe(true)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('deletes marked documents from Smartcat and unsets the items', async () => {
|
|
392
|
+
const project = {
|
|
393
|
+
_id: 'p1',
|
|
394
|
+
sourceLanguage: 'en',
|
|
395
|
+
targetLanguages: ['fr'],
|
|
396
|
+
smartcatProjectId: 'guid-1',
|
|
397
|
+
// Only About remains in the outbox; Contact is marked for deletion.
|
|
398
|
+
outbox: [ABOUT],
|
|
399
|
+
pendingDeletions: [{itemKey: 'item-contact', sourceDocId: 'contact1-bbbb', smartcatDocumentId: 'gC_12'}],
|
|
400
|
+
progress: [],
|
|
401
|
+
}
|
|
402
|
+
const {client: sanity, sets, unsets} = fakeSanity(project)
|
|
403
|
+
const {client: smartcat, deletes} = fakeSmartcat([
|
|
404
|
+
{id: 'gA_12', name: 'About-about123', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 10}]},
|
|
405
|
+
{id: 'gC_12', name: 'Contact-contact1', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 0}]},
|
|
406
|
+
])
|
|
407
|
+
|
|
408
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
409
|
+
|
|
410
|
+
expect(deletes).toEqual(['gC_12'])
|
|
411
|
+
expect(result.deleted).toBe(1)
|
|
412
|
+
// The deleted item and the transient fields are unset.
|
|
413
|
+
expect(unsets.at(-1)).toEqual(['outbox', 'pendingDeletions', 'items[_key=="item-contact"]'])
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('allows a delete-only re-sync (empty outbox)', async () => {
|
|
417
|
+
const project = {
|
|
418
|
+
_id: 'p1',
|
|
419
|
+
sourceLanguage: 'en',
|
|
420
|
+
targetLanguages: ['fr'],
|
|
421
|
+
smartcatProjectId: 'guid-1',
|
|
422
|
+
outbox: [],
|
|
423
|
+
pendingDeletions: [{itemKey: 'item-a', sourceDocId: 'about123-aaaa', smartcatDocumentId: 'gA_12'}],
|
|
424
|
+
progress: [],
|
|
425
|
+
}
|
|
426
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
427
|
+
const {client: smartcat, deletes} = fakeSmartcat([
|
|
428
|
+
{id: 'gA_12', name: 'About-about123', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 0}]},
|
|
429
|
+
])
|
|
430
|
+
|
|
431
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
|
|
432
|
+
expect(deletes).toEqual(['gA_12'])
|
|
433
|
+
expect(result).toEqual({smartcatProjectId: 'guid-1', created: 0, updated: 0, deleted: 1, failed: 0})
|
|
434
|
+
expect(sets.at(-1)!.status).toBe('sent')
|
|
435
|
+
})
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
describe('runExportUpload — chunked create path', () => {
|
|
439
|
+
it('uploads >UPLOAD_CHUNK_SIZE creates in multiple batches', async () => {
|
|
440
|
+
const outbox = Array.from({length: 120}, (_, i) => ({
|
|
441
|
+
sourceDocId: `doc${i}-${String(i).padStart(4, '0')}`,
|
|
442
|
+
title: `Doc ${i}`,
|
|
443
|
+
filename: `page/Doc${i}-doc${i}.locjson`,
|
|
444
|
+
locjson: '{"units":[]}',
|
|
445
|
+
}))
|
|
446
|
+
const project = {_id: 'p1', name: 'Big', sourceLanguage: 'en', targetLanguages: ['fr'], outbox}
|
|
447
|
+
const {client: sanity} = fakeSanity(project)
|
|
448
|
+
const {client: smartcat} = fakeSmartcat()
|
|
449
|
+
|
|
450
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T', sleep: async () => {}})
|
|
451
|
+
|
|
452
|
+
expect((smartcat.uploadDocuments as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(3) // 50+50+20
|
|
453
|
+
expect(result).toMatchObject({created: 120, failed: 0})
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('retries once as a batch before falling back', async () => {
|
|
457
|
+
const project = {_id: 'p1', name: 'Big', sourceLanguage: 'en', targetLanguages: ['fr'], outbox: [ABOUT, CONTACT]}
|
|
458
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
459
|
+
const {client: smartcat} = fakeSmartcat()
|
|
460
|
+
;(smartcat.uploadDocuments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('blip'))
|
|
461
|
+
|
|
462
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T', sleep: async () => {}})
|
|
463
|
+
|
|
464
|
+
expect((smartcat.uploadDocuments as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2) // attempt + retry
|
|
465
|
+
expect(smartcat.uploadDocument).not.toHaveBeenCalled()
|
|
466
|
+
expect(result).toMatchObject({created: 2, failed: 0})
|
|
467
|
+
expect(sets.at(-1)?.status).toBe('sent')
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('falls back to per-file after both batch attempts fail; one poison file → failed++', async () => {
|
|
471
|
+
const project = {_id: 'p1', name: 'Big', sourceLanguage: 'en', targetLanguages: ['fr'], outbox: [ABOUT, CONTACT]}
|
|
472
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
473
|
+
const {client: smartcat} = fakeSmartcat()
|
|
474
|
+
;(smartcat.uploadDocuments as ReturnType<typeof vi.fn>)
|
|
475
|
+
.mockRejectedValueOnce(new Error('blip'))
|
|
476
|
+
.mockRejectedValueOnce(new Error('blip'))
|
|
477
|
+
;(smartcat.uploadDocument as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('poison')) // ABOUT fails
|
|
478
|
+
|
|
479
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T', sleep: async () => {}})
|
|
480
|
+
|
|
481
|
+
expect((smartcat.uploadDocument as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2) // per-file over the chunk
|
|
482
|
+
expect(result).toMatchObject({created: 1, failed: 1})
|
|
483
|
+
expect(sets.at(-1)?.status).toBe('sent')
|
|
484
|
+
const log = JSON.parse(sets.at(-1)!.functionLog as string) as {level: string; message: string}[]
|
|
485
|
+
expect(log.some((l) => l.level === 'error' && /Skipped .*About/.test(l.message))).toBe(true)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('treats a 409 "already exists" in the per-file fallback as already synced, not failed', async () => {
|
|
489
|
+
const project = {_id: 'p1', name: 'Big', sourceLanguage: 'en', targetLanguages: ['fr'], outbox: [ABOUT, CONTACT]}
|
|
490
|
+
const {client: sanity, sets} = fakeSanity(project)
|
|
491
|
+
const {client: smartcat} = fakeSmartcat()
|
|
492
|
+
;(smartcat.uploadDocuments as ReturnType<typeof vi.fn>)
|
|
493
|
+
.mockRejectedValueOnce(new Error('blip'))
|
|
494
|
+
.mockRejectedValueOnce(new Error('blip'))
|
|
495
|
+
;(smartcat.uploadDocument as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
496
|
+
new SmartcatError(409, "A document with the full name 'page/About-about123' already exists."),
|
|
497
|
+
) // ABOUT already committed; CONTACT succeeds normally
|
|
498
|
+
|
|
499
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T', sleep: async () => {}})
|
|
500
|
+
|
|
501
|
+
expect(result).toMatchObject({created: 2, failed: 0})
|
|
502
|
+
expect(sets.at(-1)?.status).toBe('sent')
|
|
503
|
+
const log = JSON.parse(sets.at(-1)!.functionLog as string) as {level: string; message: string}[]
|
|
504
|
+
expect(log.some((l) => l.level === 'success' && /Already synced .*About/.test(l.message))).toBe(true)
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it('runs create-path chunk uploads with bounded concurrency', async () => {
|
|
508
|
+
// 700 docs → 14 chunks (chunk size 50), comfortably above UPLOAD_CHUNK_CONCURRENCY
|
|
509
|
+
// so the cap genuinely limits in-flight requests below the chunk count.
|
|
510
|
+
const outbox = Array.from({length: 700}, (_, i) => ({
|
|
511
|
+
sourceDocId: `doc${i}-${String(i).padStart(4, '0')}`,
|
|
512
|
+
title: `Doc ${i}`,
|
|
513
|
+
filename: `page/Doc${i}-doc${i}.locjson`,
|
|
514
|
+
locjson: '{"units":[]}',
|
|
515
|
+
}))
|
|
516
|
+
const project = {_id: 'p1', name: 'Big', sourceLanguage: 'en', targetLanguages: ['fr'], outbox}
|
|
517
|
+
const {client: sanity} = fakeSanity(project)
|
|
518
|
+
const {client: smartcat} = fakeSmartcat()
|
|
519
|
+
|
|
520
|
+
let inFlight = 0
|
|
521
|
+
let maxInFlight = 0
|
|
522
|
+
const realUploadDocuments = smartcat.uploadDocuments
|
|
523
|
+
smartcat.uploadDocuments = vi.fn(async (p: string, files: {filename: string; content: string}[]) => {
|
|
524
|
+
inFlight++
|
|
525
|
+
maxInFlight = Math.max(maxInFlight, inFlight)
|
|
526
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
527
|
+
const result = await realUploadDocuments(p, files)
|
|
528
|
+
inFlight--
|
|
529
|
+
return result
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T', sleep: async () => {}})
|
|
533
|
+
|
|
534
|
+
expect(maxInFlight).toBe(UPLOAD_CHUNK_CONCURRENCY)
|
|
535
|
+
expect(result.created).toBe(700)
|
|
536
|
+
})
|
|
537
|
+
})
|