@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.
Files changed (46) hide show
  1. package/config/default.json +13 -0
  2. package/config/test.json +12 -0
  3. package/package.json +1 -1
  4. package/scripts/declarations/validate/index.mocha.js +7 -0
  5. package/scripts/import/index.js +1 -1
  6. package/scripts/import/loadCommits.js +1 -1
  7. package/scripts/rewrite/initializer/index.js +1 -1
  8. package/scripts/rewrite/rewrite-snapshots.js +1 -1
  9. package/scripts/rewrite/rewrite-versions.js +1 -1
  10. package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
  11. package/src/archivist/fetcher/index.test.js +12 -0
  12. package/src/archivist/index.js +106 -23
  13. package/src/archivist/index.test.js +659 -9
  14. package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
  15. package/src/archivist/recorder/repositories/git/index.js +2 -13
  16. package/src/archivist/recorder/repositories/git/index.test.js +23 -1
  17. package/src/archivist/services/index.js +45 -1
  18. package/src/archivist/services/index.test.js +52 -1
  19. package/src/archivist/services/sourceDocument.js +8 -2
  20. package/src/archivist/services/sourceDocument.test.js +37 -0
  21. package/src/archivist/tracking-results/errors.js +5 -0
  22. package/src/archivist/tracking-results/index.js +233 -0
  23. package/src/archivist/tracking-results/index.test.js +406 -0
  24. package/src/archivist/tracking-results/recorder.js +156 -0
  25. package/src/archivist/tracking-results/recorder.test.js +559 -0
  26. package/src/archivist/tracking-results/repository.js +136 -0
  27. package/src/archivist/tracking-results/repository.test.js +763 -0
  28. package/src/archivist/tracking-results/run/dataMapper.js +51 -0
  29. package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
  30. package/src/archivist/tracking-results/run/index.js +115 -0
  31. package/src/archivist/tracking-results/run/index.test.js +221 -0
  32. package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
  33. package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
  34. package/src/archivist/tracking-results/terms-result/index.js +42 -0
  35. package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
  36. package/src/git/errors.js +1 -0
  37. package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +59 -9
  38. package/src/git/index.test.js +266 -0
  39. package/src/git/pathSegment.js +18 -0
  40. package/src/git/pathSegment.test.js +33 -0
  41. package/src/index.js +5 -4
  42. package/src/reporter/index.js +9 -4
  43. package/src/reporter/index.test.js +47 -9
  44. package/src/archivist/recorder/repositories/git/git.test.js +0 -114
  45. /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
  46. /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
- errorSpy.resetHistory();
549
- warnSpy.resetHistory();
550
- inaccessibleContentSpy.resetHistory();
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
- beforeEach(() => {
855
+ let recordFailureStub;
856
+
857
+ beforeEach(async () => {
581
858
  const error = new InaccessibleContentError([retryableError]);
582
859
 
583
- app.handleTrackingError(error, { terms, isRetry: true });
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
  });