Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 121 additions & 1 deletion nx/blocks/loc/connectors/smartling/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,122 @@ export async function sendAllLanguages({
await saveState({ options });
}

/**
* Extracts a human-readable message from Smartling's documented error
* envelope. Per their Error Handling docs, every 4xx/5xx response on every
* endpoint returns `{ response: { code, errors: [{ key, message,
* details }] } }` - this reads the `errors` array rather than just the
* top-level `code`, since `code` alone (e.g. `VALIDATION_ERROR`) doesn't
* say what's actually wrong (e.g. an invalid target locale).
* @param {Object} json - The parsed error response body.
* @returns {string} The joined `message` from each reported error, or the
* response `code` if no `errors` array is present.
*/
function extractErrorMessage(json) {
const errors = json?.response?.errors;
if (Array.isArray(errors) && errors.length > 0) {
return errors.map((error) => error.message).join('; ');
}
return json?.response?.code || 'Unknown error';
}

const PROCESS_POLL_INTERVAL_MS = 2000;
const MAX_PROCESS_POLL_ATTEMPTS = 30; // ~60s before giving up on an async process

function wait(ms) {
return new Promise((resolve) => { setTimeout(resolve, ms); });
}

/**
* Polls Smartling's job async-process endpoint (`getJobAsyncProcessStatus`)
* until a submitted operation reports a terminal `processState`. Used for
* the 202 case of `removeLocaleFromJob`, whose removal isn't guaranteed
* complete until this reports `COMPLETED`.
* @param {string} endpoint - The resolved Smartling API origin.
* @param {string} projectId - The Smartling project id.
* @param {string} jobUid - The job the process belongs to.
* @param {string} processUid - The process to poll.
* @returns {Promise<string>} The final `processState` ('COMPLETED' or
* 'FAILED'); also resolves to 'FAILED' if a poll request errors or the
* process doesn't finish within `MAX_PROCESS_POLL_ATTEMPTS`.
*/
async function pollJobProcess(endpoint, projectId, jobUid, processUid) {
const url = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs/${jobUid}/processes/${processUid}`;
const opts = { headers: { Authorization: `Bearer ${token}` } };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

token is module state here, so cancel/poll silently rely on connect() having run first. matches the file's existing style, just flagging in case we want it threaded through explicitly.


for (let attempt = 0; attempt < MAX_PROCESS_POLL_ATTEMPTS; attempt += 1) {
// eslint-disable-next-line no-await-in-loop
const resp = await fetch(url, opts);
if (!resp.ok) return 'FAILED';
// eslint-disable-next-line no-await-in-loop
const json = await resp.json();
const { processState } = json?.response?.data || {};
if (processState === 'COMPLETED' || processState === 'FAILED') return processState;
// eslint-disable-next-line no-await-in-loop
await wait(PROCESS_POLL_INTERVAL_MS);
}

return 'FAILED';
}

/**
* Cancels a single target language by removing its locale from the
* shared translation job (`removeLocaleFromJob`) - not Smartling's
* job-level `cancelJob` endpoint, which would cancel every other
* language still sharing that job, since `sendAllLanguages` sends every
* target language as one job. Polls the returned process to completion
* when Smartling responds 202 (async removal).
* @param {Object} params
* @param {Object} params.service - The service configuration; reads
* `origin`/`org`/`site`/`projectId`/`jobUid`.
* @param {Object} params.lang - The language to cancel; mutated in place
* with `translation.status = 'cancelled'` on success.
* @param {Function} params.sendMessage - Callback to surface a
* status/error message to the user.
* @returns {Promise<{ok: boolean, skipped?: boolean}>} Whether the
* cancellation succeeded (or was skipped as a no-op).
*/
export async function cancelTranslation({ service, lang, sendMessage }) {
if (!lang.translation || !service.jobUid?.value) {
sendMessage({ text: `Skipping ${lang.name}. No translation information.` });
return { ok: true, skipped: true };
}

const {
origin, org, site, projectId, jobUid,
} = service;
const endpoint = resolveOrigin(origin, org, site);
const translationJobUid = jobUid.value;

sendMessage({ text: `Canceling ${lang.name}.` });

const url = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs/${translationJobUid}/locales/${lang.code}`;
const opts = { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } };

const resp = await fetch(url, opts);
if (!resp.ok) {
const json = await resp.json();
sendMessage({ text: `Canceling ${lang.name} failed: ${extractErrorMessage(json)}`, type: 'error' });
return { ok: false };
}

if (resp.status === 202) {
const json = await resp.json();
const { processUid } = json?.response?.data || {};
const processState = processUid
? await pollJobProcess(endpoint, projectId, translationJobUid, processUid)
: 'FAILED';

if (processState !== 'COMPLETED') {
sendMessage({ text: `Canceling ${lang.name} did not finish in time - check Smartling directly.`, type: 'error' });
return { ok: false };
}
}

lang.translation.status = 'cancelled';
return { ok: true };
}

export async function getStatusAll({
org, site, service, langs, urls, actions,
}) {
Expand Down Expand Up @@ -278,7 +394,11 @@ export async function getStatusAll({
});
}

for (const lang of langs) {
// 'complete'/'cancelled' are terminal - Smartling keeps reporting 100%
// translated forever once done, so without this guard every subsequent
// status check would revert 'complete' back to 'translated' (triggering
// a re-save) or 'cancelled' back to 'translated' (undoing the cancel).
for (const lang of langs.filter((l) => !['complete', 'cancelled'].includes(l.translation.status))) {
if (lang.translation.translated === urls.length) {
lang.translation.status = 'translated';
}
Expand Down
21 changes: 13 additions & 8 deletions nx/blocks/loc/views/translate/translate.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,18 @@ class NxLocTranslate extends LitElement {
};
}

// A lang must have actually been sent, and not already be complete or
// cancelled, to have anything left to cancel - shared by the per-lang
// Cancel button and the project-level counts below so they can't drift
// out of sync with each other.
canCancelLang(lang) {
return !!lang.translation
&& lang.translation.status !== 'cancelled'
&& lang.translation.status !== 'complete';
}

get incompleteLangs() {
return this._translateLangs.filter((lang) => {
const status = lang.translation?.status;
if (status === 'complete') return false;
if (status === 'cancelled') return false;
return true;
}).length;
return this._translateLangs.filter((lang) => this.canCancelLang(lang)).length;
}

get canCancel() {
Expand Down Expand Up @@ -326,7 +331,7 @@ class NxLocTranslate extends LitElement {
}

renderCancelLang(lang) {
if (!this.canCancel || !this._connected || !lang.translation || lang.translation?.status === 'cancelled') return nothing;
if (!this.canCancel || !this._connected || !this.canCancelLang(lang)) return nothing;
return html`<sl-button @click=${() => this.handleCancelLang(lang)} class="primary outline">Cancel</sl-button>`;
}

Expand Down Expand Up @@ -357,7 +362,7 @@ class NxLocTranslate extends LitElement {

renderTranslate() {
if (!this._translateLangs?.length) return nothing;
const withCancel = this.canCancel && this._connected && this._translateLangs.some((lang) => lang.translation && lang.translation.status !== 'cancelled') ? ' with-cancel' : '';
const withCancel = this.canCancel && this._connected && this._translateLangs.some((lang) => this.canCancelLang(lang)) ? ' with-cancel' : '';

return html`
<div class="nx-loc-list-actions">
Expand Down
119 changes: 118 additions & 1 deletion test/loc/connectors/smartling/index.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from '@esm-bundle/chai';
import {
connect, saveItems, sendAllLanguages, getStatusAll,
connect, saveItems, sendAllLanguages, getStatusAll, cancelTranslation,
} from '../../../../nx/blocks/loc/connectors/smartling/index.js';
import { DA_TRANSLATE } from '../../../../nx2/utils/utils.js';

Expand Down Expand Up @@ -120,4 +120,121 @@ describe('smartling connector - legacy origin rewriting', () => {
const call = calls.find((c) => c.url.includes('/files-api/v2/projects'));
expect(call.url).to.include(`${DA_TRANSLATE}/translate/smartling/${org}/${site}/files-api/v2/projects/proj-1/locales/fr-FR/file`);
});

// Smartling's documented error envelope (Error Handling support article):
// every 4xx/5xx response, on every endpoint, has this shape.
function validationErrorResponse(message) {
return new Response(JSON.stringify({
response: {
code: 'VALIDATION_ERROR',
errors: [{ key: 'error.validation.job.locales.invalid', message, details: { field: 'targetLocaleIds' } }],
},
}), { status: 400 });
}

describe('cancelTranslation', () => {
it('skips cancellation when the lang has no translation info or job yet', async () => {
const service = {};
const lang = { code: 'fr-FR', name: 'French' };
const messages = [];
const sendMessage = (m) => messages.push(m);

const result = await cancelTranslation({ service, lang, sendMessage });

expect(result).to.deep.equal({ ok: true, skipped: true });
expect(calls.length).to.equal(0);
});

it('cancels a language by removing its locale from the job (200, synchronous)', async () => {
window.fetch = async (url, opts = {}) => {
const u = url.toString();
calls.push({ url: u, method: opts.method, body: opts.body });
return new Response(JSON.stringify({ response: { code: 'SUCCESS' } }), { status: 200 });
};

const service = { origin: 'https://api.smartling.com', projectId: 'proj-1', jobUid: { value: 'job-1' } };
const lang = { code: 'fr-FR', name: 'French', translation: { status: 'translated' } };
const messages = [];
const sendMessage = (m) => messages.push(m);

const result = await cancelTranslation({ service, lang, sendMessage });

expect(result).to.deep.equal({ ok: true });
expect(lang.translation.status).to.equal('cancelled');
expect(calls[0].method).to.equal('DELETE');
expect(calls[0].url).to.equal('https://api.smartling.com/jobs-api/v3/projects/proj-1/jobs/job-1/locales/fr-FR');
});

it('polls the async process to completion when Smartling responds 202', async () => {
window.fetch = async (url, opts = {}) => {
const u = url.toString();
calls.push({ url: u, method: opts.method, body: opts.body });

if (u.includes('/locales/fr-FR')) {
return new Response(JSON.stringify({ response: { data: { processUid: 'proc-1' } } }), { status: 202 });
}
if (u.includes('/processes/proc-1')) {
return new Response(JSON.stringify({ response: { data: { processState: 'COMPLETED' } } }), { status: 200 });
}
return new Response('{}', { status: 200 });
};

const service = { origin: 'https://api.smartling.com', projectId: 'proj-1', jobUid: { value: 'job-1' } };
const lang = { code: 'fr-FR', name: 'French', translation: { status: 'translated' } };
const messages = [];
const sendMessage = (m) => messages.push(m);

const result = await cancelTranslation({ service, lang, sendMessage });

expect(result).to.deep.equal({ ok: true });
expect(lang.translation.status).to.equal('cancelled');
expect(calls.some((c) => c.url.includes('/processes/proc-1'))).to.equal(true);
});

it('reports an error and does not cancel when the async process fails', async () => {
window.fetch = async (url, opts = {}) => {
const u = url.toString();
calls.push({ url: u, method: opts.method, body: opts.body });

if (u.includes('/locales/fr-FR')) {
return new Response(JSON.stringify({ response: { data: { processUid: 'proc-1' } } }), { status: 202 });
}
if (u.includes('/processes/proc-1')) {
return new Response(JSON.stringify({ response: { data: { processState: 'FAILED' } } }), { status: 200 });
}
return new Response('{}', { status: 200 });
};

const service = { origin: 'https://api.smartling.com', projectId: 'proj-1', jobUid: { value: 'job-1' } };
const lang = { code: 'fr-FR', name: 'French', translation: { status: 'translated' } };
const messages = [];
const sendMessage = (m) => messages.push(m);

const result = await cancelTranslation({ service, lang, sendMessage });

expect(result).to.deep.equal({ ok: false });
expect(lang.translation.status).to.equal('translated');
expect(messages.find((m) => m.type === 'error')).to.exist;
});

it('surfaces an error message when the cancel request itself fails', async () => {
window.fetch = async (url, opts = {}) => {
const u = url.toString();
calls.push({ url: u, method: opts.method, body: opts.body });
return validationErrorResponse('Job can be cancelled only in DRAFT, AWAITING_AUTHORIZATION, or IN_PROGRESS statuses');
};

const service = { origin: 'https://api.smartling.com', projectId: 'proj-1', jobUid: { value: 'job-1' } };
const lang = { code: 'fr-FR', name: 'French', translation: { status: 'translated' } };
const messages = [];
const sendMessage = (m) => messages.push(m);

const result = await cancelTranslation({ service, lang, sendMessage });

expect(result).to.deep.equal({ ok: false });
expect(lang.translation.status).to.equal('translated');
const errorMessage = messages.find((m) => m.type === 'error');
expect(errorMessage.text).to.include('Job can be cancelled only in DRAFT');
});
});
});
Loading