Skip to content
Merged
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
30 changes: 25 additions & 5 deletions src/translate/noswhere.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,27 @@ module.exports = class NoswhereTranslator {
constructor() {
if (!this.#noswhereKey)
throw new Error("expected NOSWHERE_KEY env var")
this.#loadTranslationLangs()
this.#loadTranslationLangs().catch((err) => {
console.error("error loading noswhere translation langs: %o", err)
})
}
#getRequestId(resp) {
return resp.headers?.get?.("x-noswhere-request") || "unknown"
}
#getBodySnippet(body) {
if (typeof body !== "string" || body.length === 0) return "<empty>"
if (body.length <= 500) return body
return body.slice(0, 500) + "...(truncated)"
}
async #parseResponse(resp, action) {
const requestId = this.#getRequestId(resp)
const body = await resp.text()
try {
return JSON.parse(body)
} catch (err) {
console.error("noswhere %s response parse error: status=%s ok=%s request=%s body=%o", action, resp.status, resp.ok, requestId, this.#getBodySnippet(body))
throw new Error(`error ${action}: invalid JSON response from Noswhere (request: ${requestId})`)
}
}
async #loadTranslationLangs() {
let resp = await fetch(this.#noswhereURL + "/langs", {
Expand All @@ -18,9 +38,9 @@ module.exports = class NoswhereTranslator {
'Content-Type': 'application/json'
}
})
let data = await resp.json()
let data = await this.#parseResponse(resp, "getting translation langs")
if (!resp.ok) {
throw new Error(`error getting translation langs: API failed with ${resp.status} ${data.error} (request: ${resp.headers.get("x-noswhere-request")})`)
throw new Error(`error getting translation langs: API failed with ${resp.status} ${data.error} (request: ${this.#getRequestId(resp)})`)
}
if (!data[this.#type]) {
throw new Error(`type ${this.#type} not supported for translation`)
Expand All @@ -46,9 +66,9 @@ module.exports = class NoswhereTranslator {
})
})

let data = await resp.json()
let data = await this.#parseResponse(resp, "translating")
if (!resp.ok) {
throw new Error(`error translating: API failed with ${resp.status} ${data.error} (request: ${resp.headers.get("x-noswhere-request")})`)
throw new Error(`error translating: API failed with ${resp.status} ${data.error} (request: ${this.#getRequestId(resp)})`)
}

if (data.result) {
Expand Down
91 changes: 91 additions & 0 deletions test/noswhere.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
const tap = require('tap');
const sinon = require('sinon');

const NOSWHERE_TRANSLATOR_PATH = require.resolve('../src/translate/noswhere.js');

function loadNoswhereTranslator() {
delete require.cache[NOSWHERE_TRANSLATOR_PATH];
return require(NOSWHERE_TRANSLATOR_PATH);
}

function mockHeaders(requestId) {
return {
get: (name) => name === 'x-noswhere-request' ? requestId : null,
};
}

tap.test('NoswhereTranslator constructor logs malformed langs responses without crashing', async (t) => {
process.env.NOSWHERE_KEY = 'test-key';

const fetchStub = sinon.stub(global, 'fetch').resolves({
ok: true,
status: 200,
headers: mockHeaders('langs-req-1'),
text: async () => '',
});
const errorStub = sinon.stub(console, 'error');

t.teardown(() => {
fetchStub.restore();
errorStub.restore();
delete process.env.NOSWHERE_KEY;
delete require.cache[NOSWHERE_TRANSLATOR_PATH];
});

const NoswhereTranslator = loadNoswhereTranslator();
const translator = new NoswhereTranslator();

await new Promise((resolve) => setImmediate(resolve));

t.equal(translator.canTranslate('en', 'ja'), true, 'translator stays usable when langs cannot be loaded');
t.ok(errorStub.calledTwice, 'malformed response is logged without escaping the constructor');
t.match(errorStub.firstCall.args[0], /noswhere %s response parse error/, 'parse failure is logged');
t.match(errorStub.firstCall.args.slice(1).join(' '), /langs-req-1/, 'request id is included in log output');
t.match(errorStub.firstCall.args.slice(1).join(' '), /<empty>/, 'response body snippet is included in log output');
t.match(errorStub.secondCall.args[0], /error loading noswhere translation langs/, 'constructor catch logs the failure summary');
});

tap.test('NoswhereTranslator translate rejects malformed JSON responses with request details', async (t) => {
process.env.NOSWHERE_KEY = 'test-key';

const fetchStub = sinon.stub(global, 'fetch');
fetchStub.onFirstCall().resolves({
ok: true,
status: 200,
headers: mockHeaders('langs-req-2'),
text: async () => JSON.stringify({
default: {
from: ['en'],
to: ['ja'],
},
}),
});
fetchStub.onSecondCall().resolves({
ok: true,
status: 200,
headers: mockHeaders('translate-req-1'),
text: async () => '{"result"',
});
const errorStub = sinon.stub(console, 'error');

t.teardown(() => {
fetchStub.restore();
errorStub.restore();
delete process.env.NOSWHERE_KEY;
delete require.cache[NOSWHERE_TRANSLATOR_PATH];
});

const NoswhereTranslator = loadNoswhereTranslator();
const translator = new NoswhereTranslator();

await new Promise((resolve) => setImmediate(resolve));

await t.rejects(
translator.translate('en', 'ja', 'hello'),
/error translating: invalid JSON response from Noswhere \(request: translate-req-1\)/,
'translate surfaces a helpful parse error'
);
t.equal(errorStub.calledOnce, true, 'translate parse failures are logged');
t.match(errorStub.firstCall.args.slice(1).join(' '), /translate-req-1/, 'translate log includes request id');
t.match(errorStub.firstCall.args.slice(1).join(' '), /{"result"/, 'translate log includes body snippet');
});
Loading