@opentermsarchive/engine 16.0.2 → 16.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.
- package/config/default.json +13 -0
- package/config/test.json +12 -0
- package/package.json +1 -1
- package/scripts/declarations/validate/index.mocha.js +7 -0
- package/scripts/import/index.js +1 -1
- package/scripts/import/loadCommits.js +1 -1
- package/scripts/rewrite/initializer/index.js +1 -1
- package/scripts/rewrite/rewrite-snapshots.js +1 -1
- package/scripts/rewrite/rewrite-versions.js +1 -1
- package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
- package/src/archivist/fetcher/index.test.js +12 -0
- package/src/archivist/index.js +106 -23
- package/src/archivist/index.test.js +659 -9
- package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
- package/src/archivist/recorder/repositories/git/index.js +2 -13
- package/src/archivist/recorder/repositories/git/index.test.js +23 -1
- package/src/archivist/services/index.js +45 -1
- package/src/archivist/services/index.test.js +52 -1
- package/src/archivist/services/sourceDocument.js +8 -2
- package/src/archivist/services/sourceDocument.test.js +37 -0
- package/src/archivist/tracking-results/errors.js +5 -0
- package/src/archivist/tracking-results/index.js +233 -0
- package/src/archivist/tracking-results/index.test.js +406 -0
- package/src/archivist/tracking-results/recorder.js +156 -0
- package/src/archivist/tracking-results/recorder.test.js +559 -0
- package/src/archivist/tracking-results/repository.js +136 -0
- package/src/archivist/tracking-results/repository.test.js +763 -0
- package/src/archivist/tracking-results/run/dataMapper.js +51 -0
- package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
- package/src/archivist/tracking-results/run/index.js +115 -0
- package/src/archivist/tracking-results/run/index.test.js +221 -0
- package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
- package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
- package/src/archivist/tracking-results/terms-result/index.js +42 -0
- package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
- package/src/git/errors.js +1 -0
- package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +59 -9
- package/src/git/index.test.js +266 -0
- package/src/git/pathSegment.js +18 -0
- package/src/git/pathSegment.test.js +33 -0
- package/src/index.js +5 -4
- package/src/reporter/index.js +9 -4
- package/src/reporter/index.test.js +47 -9
- package/src/archivist/recorder/repositories/git/git.test.js +0 -114
- /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
- /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
|
@@ -8,10 +8,13 @@ import nock from 'nock';
|
|
|
8
8
|
import sinon from 'sinon';
|
|
9
9
|
import sinonChai from 'sinon-chai';
|
|
10
10
|
|
|
11
|
+
import Git from '../git/index.js';
|
|
12
|
+
|
|
11
13
|
import { InaccessibleContentError } from './errors.js';
|
|
14
|
+
import { ExtractDocumentError } from './extract/index.js';
|
|
12
15
|
import { FetchDocumentError } from './fetcher/index.js';
|
|
13
|
-
import Git from './recorder/repositories/git/git.js';
|
|
14
16
|
import SourceDocument from './services/sourceDocument.js';
|
|
17
|
+
import TrackingResults, { MissingCollectionIdError, RUN_ID_TRAILER_KEY } from './tracking-results/index.js';
|
|
15
18
|
|
|
16
19
|
import Archivist, { EVENTS } from './index.js';
|
|
17
20
|
|
|
@@ -159,6 +162,201 @@ describe('Archivist', function () {
|
|
|
159
162
|
expect(resultingTerms).to.equal(serviceBVersionExpectedContent);
|
|
160
163
|
});
|
|
161
164
|
});
|
|
165
|
+
|
|
166
|
+
context('with tracking-results enabled', () => { // Relies on the test declarations being versioned, which holds as they live inside the engine repository
|
|
167
|
+
let archivist;
|
|
168
|
+
let repository;
|
|
169
|
+
let run;
|
|
170
|
+
let subjects;
|
|
171
|
+
|
|
172
|
+
async function trackAndReadResults() {
|
|
173
|
+
await archivist.track({ services });
|
|
174
|
+
|
|
175
|
+
run = await repository.findLatestRun();
|
|
176
|
+
subjects = (await repository.git.listCommits()).map(commit => commit.message);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
before(async () => {
|
|
180
|
+
setupNockForServices();
|
|
181
|
+
archivist = new Archivist({
|
|
182
|
+
recorderConfig: config.get('@opentermsarchive/engine.recorder'),
|
|
183
|
+
fetcherConfig: config.get('@opentermsarchive/engine.fetcher'),
|
|
184
|
+
trackingResultsConfig: config.get('@opentermsarchive/engine.tracking-results'),
|
|
185
|
+
});
|
|
186
|
+
await archivist.initialize();
|
|
187
|
+
({ repository } = archivist.trackingResults.recorder);
|
|
188
|
+
|
|
189
|
+
await trackAndReadResults();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
after(() => Promise.all([
|
|
193
|
+
repository.removeAll(),
|
|
194
|
+
archivist.recorder.snapshotsRepository.removeAll(),
|
|
195
|
+
archivist.recorder.versionsRepository.removeAll(),
|
|
196
|
+
]));
|
|
197
|
+
|
|
198
|
+
it('records the run lifecycle around the tracking of each terms', () => {
|
|
199
|
+
expect(subjects).to.deep.equal([
|
|
200
|
+
`Start run ${run.shortRunId}`,
|
|
201
|
+
`Record first tracking of ${SERVICE_A_ID} ${SERVICE_A_TYPE}`,
|
|
202
|
+
`Record first tracking of ${SERVICE_B_ID} ${SERVICE_B_TYPE}`,
|
|
203
|
+
`Complete run ${run.shortRunId} (2 ok, 0 failed)`,
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('references the commit of the declarations', () => {
|
|
208
|
+
expect(run.declarations.commit).to.match(/^[0-9a-f]{40}$/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('accounts for every declared terms', () => {
|
|
212
|
+
expect(run.coverage.processed + run.coverage.skipped.length).to.equal(run.declarations.terms);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('records the MIME type of the source documents', async () => {
|
|
216
|
+
const { event } = await repository.findLatestTermsResult(SERVICE_B_ID, SERVICE_B_TYPE);
|
|
217
|
+
|
|
218
|
+
expect(event.sourceDocuments.map(({ mimeType }) => mimeType)).to.deep.equal(['application/pdf']);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('ties the versions to the run', async () => {
|
|
222
|
+
const commit = await gitVersion.getCommit([ '--', SERVICE_A_EXPECTED_VERSION_FILE_PATH ]);
|
|
223
|
+
|
|
224
|
+
expect(commit.trailers[RUN_ID_TRAILER_KEY]).to.equal(run.runId);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('keeps the snapshot IDs of the versions readable', async () => {
|
|
228
|
+
const version = await archivist.recorder.versionsRepository.findLatest(SERVICE_A_ID, SERVICE_A_TYPE);
|
|
229
|
+
const snapshot = await archivist.recorder.snapshotsRepository.findLatest(SERVICE_A_ID, SERVICE_A_TYPE);
|
|
230
|
+
|
|
231
|
+
expect(version.snapshotIds).to.deep.equal([snapshot.id]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
context('when tracking again without any change', () => {
|
|
235
|
+
before(async () => {
|
|
236
|
+
setupNockForServices();
|
|
237
|
+
await trackAndReadResults();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('only records the run lifecycle', () => {
|
|
241
|
+
expect(subjects.slice(4)).to.deep.equal([ `Start run ${run.shortRunId}`, `Complete run ${run.shortRunId} (2 ok, 0 failed)` ]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
context('when a terms is tracked after a likely transient error', () => {
|
|
246
|
+
before(async () => {
|
|
247
|
+
setupNockForServices({ serviceA: false, serviceB: true });
|
|
248
|
+
nock('https://www.servicea.example')
|
|
249
|
+
.get('/tos')
|
|
250
|
+
.reply(503)
|
|
251
|
+
.get('/tos')
|
|
252
|
+
.reply(200, serviceASnapshotExpectedContent, { 'Content-Type': 'text/html' });
|
|
253
|
+
|
|
254
|
+
await trackAndReadResults();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('records the transient error with the success', () => {
|
|
258
|
+
expect(subjects.slice(6)).to.deep.equal([ `Start run ${run.shortRunId}`, `Record transient error of ${SERVICE_A_ID} ${SERVICE_A_TYPE}`, `Complete run ${run.shortRunId} (2 ok, 0 failed)` ]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('counts the transient error in the run', () => {
|
|
262
|
+
expect(run.transientErrors).to.equal(1);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
context('when completing the tracking-results run fails', () => {
|
|
268
|
+
let errorSpy;
|
|
269
|
+
let trackingCompletedSpy;
|
|
270
|
+
let exitStub;
|
|
271
|
+
let completeRunStub;
|
|
272
|
+
let finalizeStub;
|
|
273
|
+
|
|
274
|
+
before(async () => {
|
|
275
|
+
setupNockForServices();
|
|
276
|
+
app = await createAndInitializeArchivist();
|
|
277
|
+
|
|
278
|
+
errorSpy = sinon.spy();
|
|
279
|
+
trackingCompletedSpy = sinon.spy();
|
|
280
|
+
exitStub = sinon.stub(process, 'exit');
|
|
281
|
+
completeRunStub = sinon.stub().rejects(new Error('tracking-results commit failed'));
|
|
282
|
+
finalizeStub = sinon.stub().resolves();
|
|
283
|
+
|
|
284
|
+
app.on('error', errorSpy);
|
|
285
|
+
app.on('trackingCompleted', trackingCompletedSpy);
|
|
286
|
+
app.trackingResults = { // Faked at the boundary so completeRun can be made to fail without corrupting a real repository
|
|
287
|
+
hasRunInProgress: true,
|
|
288
|
+
startRun: sinon.stub().resolves(),
|
|
289
|
+
recordSuccess: sinon.stub().resolves(),
|
|
290
|
+
recordFailure: sinon.stub().resolves(),
|
|
291
|
+
completeRun: completeRunStub,
|
|
292
|
+
finalize: finalizeStub,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
await app.track({ services }); // Stays pending until the fatal shutdown sequence reaches its process.exit call
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
after(async () => {
|
|
299
|
+
exitStub.restore();
|
|
300
|
+
delete app.trackingResults;
|
|
301
|
+
await resetGitRepositories();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('emits a fatal error', () => {
|
|
305
|
+
expect(errorSpy).to.have.been.calledOnce;
|
|
306
|
+
expect(errorSpy.firstCall.args[0].message).to.match(/Failed to complete tracking-results run/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('does not emit trackingCompleted', () => {
|
|
310
|
+
expect(trackingCompletedSpy).to.not.have.been.called;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('triggers the fatal shutdown', () => {
|
|
314
|
+
expect(exitStub).to.have.been.calledOnceWith(1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('finalizes tracking-results before exiting', () => {
|
|
318
|
+
expect(finalizeStub).to.have.been.calledOnce;
|
|
319
|
+
expect(finalizeStub).to.have.been.calledBefore(exitStub);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
context('when tracking a subset of services', () => {
|
|
324
|
+
const DECLARATIONS_COMMIT = 'c0ffee1234567890c0ffee1234567890c0ffee12';
|
|
325
|
+
let startRunStub;
|
|
326
|
+
|
|
327
|
+
before(async () => {
|
|
328
|
+
setupNockForServices({ serviceA: true, serviceB: false });
|
|
329
|
+
app = await createAndInitializeArchivist();
|
|
330
|
+
|
|
331
|
+
startRunStub = sinon.stub().resolves();
|
|
332
|
+
app.declarationsCommit = DECLARATIONS_COMMIT;
|
|
333
|
+
app.trackingResults = {
|
|
334
|
+
hasRunInProgress: false,
|
|
335
|
+
startRun: startRunStub,
|
|
336
|
+
recordSuccess: sinon.stub().resolves(),
|
|
337
|
+
recordFailure: sinon.stub().resolves(),
|
|
338
|
+
completeRun: sinon.stub().resolves(),
|
|
339
|
+
finalize: sinon.stub().resolves(),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
await app.track({ services: ['service·A'] });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
after(async () => {
|
|
346
|
+
delete app.trackingResults;
|
|
347
|
+
delete app.declarationsCommit;
|
|
348
|
+
await resetGitRepositories();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('starts the tracking-results run with the full services, their declarations commit and the selection', () => {
|
|
352
|
+
expect(startRunStub).to.have.been.calledOnceWith({
|
|
353
|
+
services: app.services,
|
|
354
|
+
declarationsCommit: DECLARATIONS_COMMIT,
|
|
355
|
+
selectedServicesIds: ['service·A'],
|
|
356
|
+
selectedTermsTypes: [],
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|
|
162
360
|
});
|
|
163
361
|
|
|
164
362
|
describe('#applyTechnicalUpgrades', () => {
|
|
@@ -219,6 +417,41 @@ describe('Archivist', function () {
|
|
|
219
417
|
});
|
|
220
418
|
});
|
|
221
419
|
|
|
420
|
+
context('when tracking-results is enabled', () => {
|
|
421
|
+
let trackingResults;
|
|
422
|
+
|
|
423
|
+
before(async () => {
|
|
424
|
+
setupNockForServices();
|
|
425
|
+
app = await createAndInitializeArchivist();
|
|
426
|
+
await app.track({ services });
|
|
427
|
+
|
|
428
|
+
trackingResults = { // Faked at the boundary: only the calls made to the module matter here
|
|
429
|
+
hasRunInProgress: false,
|
|
430
|
+
startRun: sinon.stub().resolves(),
|
|
431
|
+
recordSuccess: sinon.stub().resolves(),
|
|
432
|
+
recordFailure: sinon.stub().resolves(),
|
|
433
|
+
completeRun: sinon.stub().resolves(),
|
|
434
|
+
finalize: sinon.stub().resolves(),
|
|
435
|
+
};
|
|
436
|
+
app.trackingResults = trackingResults;
|
|
437
|
+
|
|
438
|
+
await app.applyTechnicalUpgrades({ services });
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
after(async () => {
|
|
442
|
+
delete app.trackingResults;
|
|
443
|
+
await resetGitRepositories();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('does not start a tracking-results run', () => {
|
|
447
|
+
expect(trackingResults.startRun).to.not.have.been.called;
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('does not complete a tracking-results run', () => {
|
|
451
|
+
expect(trackingResults.completeRun).to.not.have.been.called;
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
222
455
|
context('when there is an operational error with service A', () => {
|
|
223
456
|
let inaccessibleContentSpy;
|
|
224
457
|
let versionNotChangedSpy;
|
|
@@ -512,6 +745,37 @@ describe('Archivist', function () {
|
|
|
512
745
|
});
|
|
513
746
|
});
|
|
514
747
|
|
|
748
|
+
describe('#trackTermsChanges', () => {
|
|
749
|
+
context('when the tracking-results success cannot be recorded', () => {
|
|
750
|
+
let app;
|
|
751
|
+
let errorSpy;
|
|
752
|
+
let recordFailureStub;
|
|
753
|
+
|
|
754
|
+
before(async () => {
|
|
755
|
+
app = await createAndInitializeArchivist();
|
|
756
|
+
app.removeAllListeners('error'); // Detach the fatal shutdown listener registered at initialize, as this context deliberately emits the fatal error event
|
|
757
|
+
errorSpy = sinon.spy();
|
|
758
|
+
app.on('error', errorSpy);
|
|
759
|
+
recordFailureStub = sinon.stub().resolves();
|
|
760
|
+
app.trackingResults = { recordSuccess: sinon.stub().rejects(new Error('Could not commit')), recordFailure: recordFailureStub };
|
|
761
|
+
sinon.stub(app, 'fetchAndRecordSnapshots').resolves();
|
|
762
|
+
sinon.stub(app, 'extractContentsFromSnapshots').resolves(['content']);
|
|
763
|
+
sinon.stub(app, 'recordVersion').resolves();
|
|
764
|
+
|
|
765
|
+
await app.trackTermsChanges({ terms: { service: { id: 'test-service' }, type: 'test-type', sourceDocuments: [{}] } });
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('emits a fatal error', () => {
|
|
769
|
+
expect(errorSpy).to.have.been.calledOnce;
|
|
770
|
+
expect(errorSpy.firstCall.args[0].message).to.match(/Could not record the tracking-results outcome/);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('does not record the successfully tracked terms as failed', () => {
|
|
774
|
+
expect(recordFailureStub).to.not.have.been.called;
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
515
779
|
describe('#handleTrackingError', () => {
|
|
516
780
|
let errorSpy;
|
|
517
781
|
let warnSpy;
|
|
@@ -523,6 +787,7 @@ describe('Archivist', function () {
|
|
|
523
787
|
|
|
524
788
|
before(async () => {
|
|
525
789
|
app = await createAndInitializeArchivist();
|
|
790
|
+
app.trackingQueue.pause(); // These tests assert what gets pushed onto the queue, not its processing: a live worker would fetch the plain-object fixtures in the background and its failure would race the following suites
|
|
526
791
|
});
|
|
527
792
|
|
|
528
793
|
beforeEach(() => {
|
|
@@ -545,18 +810,18 @@ describe('Archivist', function () {
|
|
|
545
810
|
});
|
|
546
811
|
|
|
547
812
|
afterEach(() => {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
813
|
+
app.removeListener('error', errorSpy);
|
|
814
|
+
app.removeListener('warn', warnSpy);
|
|
815
|
+
app.removeListener('inaccessibleContent', inaccessibleContentSpy);
|
|
551
816
|
pushSpy.restore();
|
|
552
817
|
});
|
|
553
818
|
|
|
554
819
|
context('with an InaccessibleContentError', () => {
|
|
555
820
|
context('when error may be transient', () => {
|
|
556
|
-
beforeEach(() => {
|
|
821
|
+
beforeEach(async () => {
|
|
557
822
|
const error = new InaccessibleContentError([retryableError]);
|
|
558
823
|
|
|
559
|
-
app.handleTrackingError(error, { terms });
|
|
824
|
+
await app.handleTrackingError(error, { terms });
|
|
560
825
|
});
|
|
561
826
|
|
|
562
827
|
it('does not emit an error event', () => {
|
|
@@ -572,15 +837,34 @@ describe('Archivist', function () {
|
|
|
572
837
|
});
|
|
573
838
|
|
|
574
839
|
it('pushes terms to tracking queue for retry', () => {
|
|
575
|
-
expect(pushSpy).to.have.been.calledWith({ terms, isRetry: true });
|
|
840
|
+
expect(pushSpy).to.have.been.calledWith(sinon.match({ terms, isRetry: true }));
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('propagates the transient errors to the retry', () => {
|
|
844
|
+
expect(pushSpy).to.have.been.calledWith(sinon.match({ transientErrors: [retryableError] }));
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('keeps the technical upgrade flag on the retry', async () => {
|
|
848
|
+
await app.handleTrackingError(new InaccessibleContentError([retryableError]), { terms, technicalUpgradeOnly: true });
|
|
849
|
+
|
|
850
|
+
expect(pushSpy).to.have.been.calledWith(sinon.match({ isRetry: true, technicalUpgradeOnly: true }));
|
|
576
851
|
});
|
|
577
852
|
});
|
|
578
853
|
|
|
579
854
|
context('when error comes from a retry', () => {
|
|
580
|
-
|
|
855
|
+
let recordFailureStub;
|
|
856
|
+
|
|
857
|
+
beforeEach(async () => {
|
|
581
858
|
const error = new InaccessibleContentError([retryableError]);
|
|
582
859
|
|
|
583
|
-
|
|
860
|
+
recordFailureStub = sinon.stub().resolves();
|
|
861
|
+
app.trackingResults = { recordFailure: recordFailureStub };
|
|
862
|
+
|
|
863
|
+
await app.handleTrackingError(error, { terms, isRetry: true });
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
afterEach(() => {
|
|
867
|
+
delete app.trackingResults;
|
|
584
868
|
});
|
|
585
869
|
|
|
586
870
|
it('does not emit an error event', () => {
|
|
@@ -598,6 +882,210 @@ describe('Archivist', function () {
|
|
|
598
882
|
it('does not push terms to tracking queue for retry', () => {
|
|
599
883
|
expect(pushSpy).to.not.have.been.called;
|
|
600
884
|
});
|
|
885
|
+
|
|
886
|
+
it('records the failure with the underlying errors', () => {
|
|
887
|
+
expect(recordFailureStub).to.have.been.calledOnceWith(terms, [retryableError]);
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
context('when error is not transient', () => {
|
|
892
|
+
const errors = [ new FetchDocumentError('HTTP code 404'), new ExtractDocumentError('CSS selector has no match') ];
|
|
893
|
+
let recordFailureStub;
|
|
894
|
+
|
|
895
|
+
beforeEach(async () => {
|
|
896
|
+
recordFailureStub = sinon.stub().resolves();
|
|
897
|
+
app.trackingResults = { recordFailure: recordFailureStub };
|
|
898
|
+
|
|
899
|
+
await app.handleTrackingError(new InaccessibleContentError(errors), { terms });
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
afterEach(() => {
|
|
903
|
+
delete app.trackingResults;
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('emits an inaccessibleContent event', () => {
|
|
907
|
+
expect(inaccessibleContentSpy).to.have.been.called;
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('does not push terms to tracking queue for retry', () => {
|
|
911
|
+
expect(pushSpy).to.not.have.been.called;
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('records the failure with the underlying errors', () => {
|
|
915
|
+
expect(recordFailureStub).to.have.been.calledOnceWith(terms, errors);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
context('when the failure cannot be recorded', () => {
|
|
920
|
+
beforeEach(async () => {
|
|
921
|
+
app.removeAllListeners('error'); // Detach the fatal shutdown listener registered at initialize, as this context deliberately emits the fatal error event
|
|
922
|
+
app.on('error', errorSpy);
|
|
923
|
+
app.trackingResults = { recordFailure: sinon.stub().rejects(new Error('Could not commit')) };
|
|
924
|
+
|
|
925
|
+
await app.handleTrackingError(new InaccessibleContentError([new FetchDocumentError('HTTP code 404')]), { terms });
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
afterEach(() => {
|
|
929
|
+
delete app.trackingResults;
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('emits the fatal error event', () => {
|
|
933
|
+
expect(errorSpy).to.have.been.calledOnce;
|
|
934
|
+
expect(errorSpy.firstCall.args[0].message).to.include('Could not commit');
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
context('with an error that is not an InaccessibleContentError', () => {
|
|
940
|
+
let recordFailureStub;
|
|
941
|
+
|
|
942
|
+
beforeEach(async () => {
|
|
943
|
+
app.removeAllListeners('error'); // Detach the fatal shutdown listener registered at initialize, as this context deliberately emits the fatal error event
|
|
944
|
+
app.on('error', errorSpy);
|
|
945
|
+
recordFailureStub = sinon.stub().resolves();
|
|
946
|
+
app.trackingResults = { recordFailure: recordFailureStub };
|
|
947
|
+
|
|
948
|
+
await app.handleTrackingError(new Error('boom'), { terms });
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
afterEach(() => {
|
|
952
|
+
delete app.trackingResults;
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it('emits the fatal error event', () => {
|
|
956
|
+
expect(errorSpy).to.have.been.calledOnce;
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('records the failure before emitting the fatal error', () => {
|
|
960
|
+
expect(recordFailureStub).to.have.been.calledOnce;
|
|
961
|
+
expect(recordFailureStub).to.have.been.calledBefore(errorSpy);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it('passes the original error for categorisation', () => {
|
|
965
|
+
expect(recordFailureStub.firstCall.args[1].map(recordedError => recordedError.message)).to.deep.equal(['boom']);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
context('when the failure cannot be recorded', () => {
|
|
969
|
+
beforeEach(async () => {
|
|
970
|
+
errorSpy.resetHistory();
|
|
971
|
+
app.trackingResults = { recordFailure: sinon.stub().rejects(new Error('Could not commit')) };
|
|
972
|
+
|
|
973
|
+
await app.handleTrackingError(new Error('boom'), { terms });
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('warns about it before emitting the fatal error event', () => {
|
|
977
|
+
expect(warnSpy).to.have.been.calledWithMatch({ message: sinon.match('Could not record the tracking outcome before shutdown') });
|
|
978
|
+
expect(warnSpy).to.have.been.calledBefore(errorSpy);
|
|
979
|
+
expect(errorSpy).to.have.been.calledOnce;
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
describe('fatal error shutdown', () => {
|
|
986
|
+
let archivist;
|
|
987
|
+
let exitStub;
|
|
988
|
+
let finalizeSpy;
|
|
989
|
+
|
|
990
|
+
before(async function () {
|
|
991
|
+
this.timeout(10000);
|
|
992
|
+
archivist = await createAndInitializeArchivist();
|
|
993
|
+
exitStub = sinon.stub(process, 'exit');
|
|
994
|
+
finalizeSpy = sinon.spy(archivist.recorder, 'finalize');
|
|
995
|
+
|
|
996
|
+
archivist.emit('error', { message: 'first fatal error' });
|
|
997
|
+
archivist.emit('error', { message: 'second fatal error' });
|
|
998
|
+
|
|
999
|
+
await archivist.fatalShutdownPromise; // The shutdown sequence is asynchronous; wait until it reaches its process.exit call
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
after(() => {
|
|
1003
|
+
exitStub.restore();
|
|
1004
|
+
finalizeSpy.restore();
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it('runs the cleanup sequence only once', () => {
|
|
1008
|
+
expect(finalizeSpy).to.have.been.calledOnce;
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('exits the process once, with the expected exit code', () => {
|
|
1012
|
+
expect(exitStub).to.have.been.calledOnceWith(1);
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
describe('#extractContentsFromSnapshots', () => {
|
|
1017
|
+
context('when several source documents fail extraction', () => {
|
|
1018
|
+
let app;
|
|
1019
|
+
let error;
|
|
1020
|
+
let getLatestSnapshotStub;
|
|
1021
|
+
|
|
1022
|
+
before(async () => {
|
|
1023
|
+
app = await createAndInitializeArchivist();
|
|
1024
|
+
|
|
1025
|
+
const terms = {
|
|
1026
|
+
service: { id: 'test-service' },
|
|
1027
|
+
type: 'test-type',
|
|
1028
|
+
sourceDocuments: [
|
|
1029
|
+
{ id: 'doc1', location: 'https://example.com/doc1' },
|
|
1030
|
+
{ id: 'doc2', location: 'https://example.com/doc2' },
|
|
1031
|
+
],
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
getLatestSnapshotStub = sinon.stub(app.recorder, 'getLatestSnapshot').callsFake((_, sourceDocumentId) => Promise.resolve({
|
|
1035
|
+
id: `snapshot-of-${sourceDocumentId}`,
|
|
1036
|
+
content: 'content',
|
|
1037
|
+
mimeType: 'text/html',
|
|
1038
|
+
fetchDate: FETCH_DATE,
|
|
1039
|
+
}));
|
|
1040
|
+
|
|
1041
|
+
app.extract = sourceDocument => new Promise((resolve, reject) => {
|
|
1042
|
+
const delay = sourceDocument.id === 'doc1' ? 20 : 0; // Make the first declared document fail last, so an ordering based on completion time would be exposed
|
|
1043
|
+
|
|
1044
|
+
setTimeout(() => reject(new ExtractDocumentError(`extraction failure of ${sourceDocument.id}`)), delay);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
try {
|
|
1048
|
+
await app.extractContentsFromSnapshots(terms);
|
|
1049
|
+
} catch (thrownError) {
|
|
1050
|
+
error = thrownError;
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
after(() => getLatestSnapshotStub.restore());
|
|
1055
|
+
|
|
1056
|
+
it('throws an InaccessibleContentError', () => {
|
|
1057
|
+
expect(error).to.be.an.instanceOf(InaccessibleContentError);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it('collects the errors in the source documents declaration order', () => {
|
|
1061
|
+
expect(error.errors.map(extractError => extractError.message)).to.deep.equal([ 'Extract failed: extraction failure of doc1', 'Extract failed: extraction failure of doc2' ]);
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
describe('#fetchSourceDocument', () => {
|
|
1067
|
+
context('when the fetch fails after a previous successful run', () => {
|
|
1068
|
+
let app;
|
|
1069
|
+
let error;
|
|
1070
|
+
let sourceDocument;
|
|
1071
|
+
|
|
1072
|
+
before(async () => {
|
|
1073
|
+
app = await createAndInitializeArchivist();
|
|
1074
|
+
sourceDocument = new SourceDocument({ location: 'https://example.com/terms', contentSelectors: 'body' });
|
|
1075
|
+
sourceDocument.mimeType = 'text/html'; // Observations left by a previous run on the long-lived services map
|
|
1076
|
+
sourceDocument.snapshotId = 'stale123';
|
|
1077
|
+
app.fetch = () => Promise.reject(new FetchDocumentError('HTTP code 500'));
|
|
1078
|
+
|
|
1079
|
+
error = await app.fetchSourceDocument(sourceDocument);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it('reports the fetch error', () => {
|
|
1083
|
+
expect(error).to.be.an.instanceOf(FetchDocumentError);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it('clears the stale observations so the failure is recorded with null values', () => {
|
|
1087
|
+
expect(sourceDocument.mimeType).to.be.null;
|
|
1088
|
+
expect(sourceDocument.snapshotId).to.be.null;
|
|
601
1089
|
});
|
|
602
1090
|
});
|
|
603
1091
|
});
|
|
@@ -1027,4 +1515,166 @@ describe('Archivist', function () {
|
|
|
1027
1515
|
});
|
|
1028
1516
|
});
|
|
1029
1517
|
});
|
|
1518
|
+
|
|
1519
|
+
describe('tracking-results wiring', () => { // Unit-level wiring tests that do not require a live fetcher; the integration with a real tracking is covered in #track
|
|
1520
|
+
context('when no trackingResultsConfig is provided', () => {
|
|
1521
|
+
let archivist;
|
|
1522
|
+
|
|
1523
|
+
before(async () => {
|
|
1524
|
+
archivist = new Archivist({
|
|
1525
|
+
recorderConfig: config.get('@opentermsarchive/engine.recorder'),
|
|
1526
|
+
fetcherConfig: config.get('@opentermsarchive/engine.fetcher'),
|
|
1527
|
+
});
|
|
1528
|
+
await archivist.initialize();
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
after(async () => {
|
|
1532
|
+
await Promise.all([
|
|
1533
|
+
archivist.recorder.snapshotsRepository.removeAll(),
|
|
1534
|
+
archivist.recorder.versionsRepository.removeAll(),
|
|
1535
|
+
]);
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
it('does not construct the tracking-results module', () => {
|
|
1539
|
+
expect(archivist.trackingResults).to.be.undefined;
|
|
1540
|
+
});
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
context('when a trackingResultsConfig is provided', () => {
|
|
1544
|
+
let archivist;
|
|
1545
|
+
let warnSpy;
|
|
1546
|
+
|
|
1547
|
+
before(async () => {
|
|
1548
|
+
warnSpy = sinon.spy();
|
|
1549
|
+
archivist = new Archivist({
|
|
1550
|
+
recorderConfig: config.get('@opentermsarchive/engine.recorder'),
|
|
1551
|
+
fetcherConfig: config.get('@opentermsarchive/engine.fetcher'),
|
|
1552
|
+
trackingResultsConfig: config.get('@opentermsarchive/engine.tracking-results'),
|
|
1553
|
+
});
|
|
1554
|
+
archivist.on('warn', warnSpy);
|
|
1555
|
+
await archivist.initialize();
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
after(async () => {
|
|
1559
|
+
await Promise.all([
|
|
1560
|
+
archivist.trackingResults?.recorder.repository.removeAll(),
|
|
1561
|
+
archivist.recorder.snapshotsRepository.removeAll(),
|
|
1562
|
+
archivist.recorder.versionsRepository.removeAll(),
|
|
1563
|
+
]);
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
it('constructs and initialises the tracking-results module', () => {
|
|
1567
|
+
expect(archivist.trackingResults).to.exist;
|
|
1568
|
+
expect(archivist.trackingResults.recorder.repository).to.exist;
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it('captures the commit of the loaded declarations', () => {
|
|
1572
|
+
expect(archivist.declarationsCommit).to.match(/^[0-9a-f]{40}$/); // The test declarations live inside the engine repository, so their commit is the engine HEAD
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
it('resolves the collection identity and engine version', () => {
|
|
1576
|
+
expect(archivist.trackingResults.recorder.collectionId).to.equal('test'); // From test/test-declarations/metadata.yml
|
|
1577
|
+
expect(archivist.trackingResults.recorder.engineVersion).to.be.a('string');
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
it('relays the module warnings on the engine event surface', () => {
|
|
1581
|
+
archivist.trackingResults.emit('warn', { message: 'relayed warning' });
|
|
1582
|
+
expect(warnSpy).to.have.been.calledWithMatch({ message: 'relayed warning' });
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
context('when recording snapshots and versions', () => {
|
|
1587
|
+
const RUN_ID = 'ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
|
1588
|
+
let archivist;
|
|
1589
|
+
let terms;
|
|
1590
|
+
|
|
1591
|
+
before(async () => {
|
|
1592
|
+
archivist = new Archivist({
|
|
1593
|
+
recorderConfig: config.get('@opentermsarchive/engine.recorder'),
|
|
1594
|
+
fetcherConfig: config.get('@opentermsarchive/engine.fetcher'),
|
|
1595
|
+
});
|
|
1596
|
+
await archivist.initialize();
|
|
1597
|
+
terms = archivist.services.service·A.getTerms({ type: SERVICE_A_TYPE });
|
|
1598
|
+
terms.fetchDate = FETCH_DATE;
|
|
1599
|
+
terms.sourceDocuments.forEach(sourceDocument => {
|
|
1600
|
+
sourceDocument.content = serviceASnapshotExpectedContent;
|
|
1601
|
+
sourceDocument.mimeType = MIME_TYPE;
|
|
1602
|
+
});
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
after(async () => {
|
|
1606
|
+
await Promise.all([
|
|
1607
|
+
archivist.recorder.snapshotsRepository.removeAll(),
|
|
1608
|
+
archivist.recorder.versionsRepository.removeAll(),
|
|
1609
|
+
]);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
context('while a tracking-results run is in progress', () => {
|
|
1613
|
+
before(() => {
|
|
1614
|
+
archivist.trackingResults = { currentRunId: RUN_ID }; // Faked at the boundary: only the run identity matters to the records
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
after(() => {
|
|
1618
|
+
delete archivist.trackingResults;
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
it('ties the snapshot to the run through its metadata', async () => {
|
|
1622
|
+
const snapshot = await archivist.recordSnapshot(terms, terms.sourceDocuments[0]);
|
|
1623
|
+
|
|
1624
|
+
expect(snapshot.metadata[RUN_ID_TRAILER_KEY]).to.equal(RUN_ID);
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
it('ties the version to the run through its metadata', async () => {
|
|
1628
|
+
const version = await archivist.recordVersion(terms, 'content');
|
|
1629
|
+
|
|
1630
|
+
expect(version.metadata[RUN_ID_TRAILER_KEY]).to.equal(RUN_ID);
|
|
1631
|
+
});
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
context('without an active tracking-results run', () => {
|
|
1635
|
+
it('records no run id on the snapshot', async () => {
|
|
1636
|
+
const snapshot = await archivist.recordSnapshot(terms, terms.sourceDocuments[0]);
|
|
1637
|
+
|
|
1638
|
+
expect(snapshot.metadata).to.not.have.property(RUN_ID_TRAILER_KEY);
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
it('records no run id on the version', async () => {
|
|
1642
|
+
const version = await archivist.recordVersion(terms, 'content');
|
|
1643
|
+
|
|
1644
|
+
expect(version.metadata).to.not.have.property(RUN_ID_TRAILER_KEY);
|
|
1645
|
+
});
|
|
1646
|
+
});
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
context('when the collection has no id', () => {
|
|
1650
|
+
let archivist;
|
|
1651
|
+
let warnSpy;
|
|
1652
|
+
let createStub;
|
|
1653
|
+
|
|
1654
|
+
before(async () => {
|
|
1655
|
+
createStub = sinon.stub(TrackingResults, 'create').rejects(new MissingCollectionIdError('Collection metadata "id" is required to record tracking-results.'));
|
|
1656
|
+
warnSpy = sinon.spy();
|
|
1657
|
+
archivist = new Archivist({
|
|
1658
|
+
recorderConfig: config.get('@opentermsarchive/engine.recorder'),
|
|
1659
|
+
fetcherConfig: config.get('@opentermsarchive/engine.fetcher'),
|
|
1660
|
+
trackingResultsConfig: config.get('@opentermsarchive/engine.tracking-results'),
|
|
1661
|
+
});
|
|
1662
|
+
archivist.on('warn', warnSpy);
|
|
1663
|
+
await archivist.initialize();
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
after(async () => {
|
|
1667
|
+
createStub.restore();
|
|
1668
|
+
await Promise.all([
|
|
1669
|
+
archivist.recorder.snapshotsRepository.removeAll(),
|
|
1670
|
+
archivist.recorder.versionsRepository.removeAll(),
|
|
1671
|
+
]);
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
it('disables tracking-results and keeps tracking available', () => {
|
|
1675
|
+
expect(archivist.trackingResults).to.be.undefined;
|
|
1676
|
+
expect(warnSpy.args.map(([payload]) => payload.message).join('\n')).to.include('Tracking-results is disabled');
|
|
1677
|
+
});
|
|
1678
|
+
});
|
|
1679
|
+
});
|
|
1030
1680
|
});
|