@opentermsarchive/engine 16.0.2 → 16.1.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.
Files changed (51) 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/factory.js +2 -2
  43. package/src/reporter/github/index.js +7 -5
  44. package/src/reporter/github/index.test.js +16 -1
  45. package/src/reporter/gitlab/index.js +6 -6
  46. package/src/reporter/gitlab/index.test.js +24 -1
  47. package/src/reporter/index.js +9 -4
  48. package/src/reporter/index.test.js +74 -9
  49. package/src/archivist/recorder/repositories/git/git.test.js +0 -114
  50. /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
  51. /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
@@ -0,0 +1,559 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ import { expect } from 'chai';
6
+ import sinon from 'sinon';
7
+
8
+ import { UnreadableRunError } from './errors.js';
9
+ import TrackingResultsRecorder from './recorder.js';
10
+ import TrackingResultsRepository from './repository.js';
11
+ import Run from './run/index.js';
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ const REPOSITORY_PATH = path.resolve(__dirname, '../../../test/data/tracking-results');
16
+
17
+ const AUTHOR = {
18
+ name: 'Open Terms Archive Testing Bot',
19
+ email: 'bot@opentermsarchive.org',
20
+ };
21
+
22
+ const COLLECTION_ID = 'france';
23
+ const SCHEDULE = '30 */12 * * *';
24
+ const ENGINE_VERSION = '12.0.0';
25
+ const DECLARATIONS_COMMIT = 'abc123def456';
26
+
27
+ const SOURCE_DOCUMENTS = [{
28
+ id: 'main',
29
+ fetch: 'https://www.facebook.com/legal/terms',
30
+ select: '.content',
31
+ remove: '',
32
+ filter: [],
33
+ executeClientScripts: false,
34
+ mimeType: 'text/html',
35
+ snapshotId: 'def456',
36
+ }];
37
+
38
+ function makeOutcome({
39
+ serviceId = 'Facebook',
40
+ termsType = 'Terms of Service',
41
+ serviceName = 'Facebook',
42
+ sourceDocuments = SOURCE_DOCUMENTS,
43
+ status = 'ok',
44
+ reasons,
45
+ transientErrorReasons,
46
+ } = {}) {
47
+ return { serviceId, termsType, serviceName, sourceDocuments, status, reasons, transientErrorReasons };
48
+ }
49
+
50
+ describe('TrackingResultsRecorder', () => {
51
+ let repository;
52
+ let subject;
53
+
54
+ before(async function () {
55
+ this.timeout(5000);
56
+
57
+ repository = new TrackingResultsRepository({ path: REPOSITORY_PATH, author: AUTHOR, publish: false });
58
+ subject = new TrackingResultsRecorder({ repository, collectionId: COLLECTION_ID, schedule: SCHEDULE, engineVersion: ENGINE_VERSION });
59
+ await subject.initialize();
60
+ });
61
+
62
+ after(() => repository.removeAll());
63
+
64
+ describe('#startRun', () => {
65
+ let run;
66
+
67
+ before(async () => {
68
+ run = await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
69
+ });
70
+
71
+ after(() => repository.removeAll());
72
+
73
+ it('returns a Run instance with a freshly minted runId', () => {
74
+ expect(run).to.be.instanceOf(Run);
75
+ expect(run.runId).to.match(/^ota-run-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
76
+ });
77
+
78
+ it('captures collectionId, schedule and engineVersion from the recorder configuration', () => {
79
+ expect(run.collectionId).to.equal(COLLECTION_ID);
80
+ expect(run.schedule).to.equal(SCHEDULE);
81
+ expect(run.lastRun.engineVersion).to.equal(ENGINE_VERSION);
82
+ });
83
+
84
+ it('captures the declarations commit and counts from the call arguments', () => {
85
+ expect(run.declarations).to.deep.equal({ commit: DECLARATIONS_COMMIT, services: 1, terms: 1 });
86
+ });
87
+
88
+ it('persists the run with status "in_progress"', async () => {
89
+ const persisted = await repository.findLatestRun();
90
+
91
+ expect(persisted.lastRun.status).to.equal('in_progress');
92
+ expect(persisted.runId).to.equal(run.runId);
93
+ });
94
+
95
+ it('exposes the run via currentRun', () => {
96
+ expect(subject.currentRun).to.equal(run);
97
+ });
98
+
99
+ it('ties the run-start commit to the run through the x-run-id trailer', async () => {
100
+ const commit = await repository.git.getCommit([ '--', 'run.json' ]);
101
+
102
+ expect(commit.trailers['x-run-id']).to.equal(run.runId);
103
+ });
104
+
105
+ context('when the run cannot be persisted', () => {
106
+ it('does not expose a current run', async () => {
107
+ const invalidSubject = new TrackingResultsRecorder({ repository, collectionId: undefined, schedule: SCHEDULE, engineVersion: ENGINE_VERSION }); // A missing collectionId makes Run.validate throw inside saveRun
108
+
109
+ try {
110
+ await invalidSubject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
111
+ } catch (error) {
112
+ expect(error.message).to.match(/collectionId/);
113
+ expect(invalidSubject.currentRun).to.be.null;
114
+
115
+ return;
116
+ }
117
+
118
+ expect.fail('No error was thrown');
119
+ });
120
+ });
121
+
122
+ context('with terms skipped from the start', () => {
123
+ const SKIPPED = [{ serviceId: 'Facebook', termsType: 'Imprint', reason: 'not selected for this run' }];
124
+
125
+ before(async () => {
126
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 3, skippedTerms: SKIPPED });
127
+ });
128
+
129
+ it('persists the skips in the run.json committed at run start', async () => { // So a crash during the run cannot let recovery attribute them to the crash
130
+ const persisted = await repository.findLatestRun();
131
+
132
+ expect(persisted.lastRun.status).to.equal('in_progress');
133
+ expect(persisted.coverage.skipped).to.deep.equal(SKIPPED);
134
+ });
135
+ });
136
+ });
137
+
138
+ describe('#recordTermsOutcome', () => {
139
+ context('when called before startRun', () => {
140
+ before(() => { subject.discardCurrentRun(); }); // Reset state left by an earlier describe
141
+
142
+ it('throws an error', async () => {
143
+ try {
144
+ await subject.recordTermsOutcome(makeOutcome());
145
+ expect.fail('expected recordTermsOutcome to throw');
146
+ } catch (error) {
147
+ expect(error.message).to.match(/no tracking run in progress/);
148
+ }
149
+ });
150
+ });
151
+
152
+ context('first record for a terms', () => {
153
+ before(async () => {
154
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 2 });
155
+ await subject.recordTermsOutcome(makeOutcome());
156
+ });
157
+
158
+ after(() => repository.removeAll());
159
+
160
+ it('persists the TermsResult via the repository', async () => {
161
+ const persisted = await repository.findLatestTermsResult('Facebook', 'Terms of Service');
162
+
163
+ expect(persisted.status).to.equal('ok');
164
+ expect(persisted.event.serviceName).to.equal('Facebook');
165
+ });
166
+
167
+ it('increments coverage.processed', () => {
168
+ expect(subject.currentRun.coverage.processed).to.equal(1);
169
+ });
170
+
171
+ it('does not record a transition for FIRST_TRACKING', () => {
172
+ expect(subject.currentRun.transitions).to.deep.equal({ newFailures: [], recoveries: [], reasonChanges: [] });
173
+ });
174
+ });
175
+
176
+ context('first record for a failing terms', () => {
177
+ before(async () => {
178
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
179
+ await subject.recordTermsOutcome(makeOutcome({ status: 'failed', reasons: ['[fetch] HTTP 500'] }));
180
+ });
181
+
182
+ after(() => repository.removeAll());
183
+
184
+ it('does not record a transition', () => {
185
+ expect(subject.currentRun.transitions).to.deep.equal({ newFailures: [], recoveries: [], reasonChanges: [] });
186
+ });
187
+ });
188
+
189
+ context('when the status transitions ok to failed', () => {
190
+ before(async () => {
191
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
192
+ await subject.recordTermsOutcome(makeOutcome());
193
+ await subject.recordTermsOutcome(makeOutcome({ status: 'failed', reasons: ['[fetch] HTTP 500'] }));
194
+ });
195
+
196
+ after(() => repository.removeAll());
197
+
198
+ it('appends the terms to newFailures', () => {
199
+ expect(subject.currentRun.transitions.newFailures).to.deep.equal([{ serviceId: 'Facebook', termsType: 'Terms of Service' }]);
200
+ expect(subject.currentRun.transitions.recoveries).to.deep.equal([]);
201
+ expect(subject.currentRun.transitions.reasonChanges).to.deep.equal([]);
202
+ });
203
+ });
204
+
205
+ context('when the status transitions failed to ok', () => {
206
+ before(async () => {
207
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
208
+ await subject.recordTermsOutcome(makeOutcome({ status: 'failed', reasons: ['[fetch] HTTP 500'] }));
209
+ await subject.recordTermsOutcome(makeOutcome());
210
+ });
211
+
212
+ after(() => repository.removeAll());
213
+
214
+ it('appends the terms to recoveries', () => {
215
+ expect(subject.currentRun.transitions.recoveries).to.deep.equal([{ serviceId: 'Facebook', termsType: 'Terms of Service' }]);
216
+ });
217
+ });
218
+
219
+ context('when failure reasons change between two runs', () => {
220
+ before(async () => {
221
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
222
+ await subject.recordTermsOutcome(makeOutcome({ status: 'failed', reasons: ['[fetch] HTTP 500'] }));
223
+ await subject.recordTermsOutcome(makeOutcome({ status: 'failed', reasons: ['[fetch] HTTP 404'] }));
224
+ });
225
+
226
+ after(() => repository.removeAll());
227
+
228
+ it('appends the terms to reasonChanges', () => {
229
+ expect(subject.currentRun.transitions.reasonChanges).to.deep.equal([{ serviceId: 'Facebook', termsType: 'Terms of Service' }]);
230
+ });
231
+ });
232
+
233
+ context('when a transient error is reported alongside an ok outcome', () => {
234
+ before(async () => {
235
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
236
+ await subject.recordTermsOutcome(makeOutcome({ transientErrorReasons: ['[fetch] HTTP 503'] }));
237
+ });
238
+
239
+ after(() => repository.removeAll());
240
+
241
+ it('increments transientErrors on the current run', () => {
242
+ expect(subject.currentRun.transientErrors).to.equal(1);
243
+ });
244
+
245
+ it('persists the transientError on the per-terms file', async () => {
246
+ const persisted = await repository.findLatestTermsResult('Facebook', 'Terms of Service');
247
+
248
+ expect(persisted.event.transientError).to.deep.equal({ reasons: ['[fetch] HTTP 503'] });
249
+ });
250
+ });
251
+
252
+ context('when the outcome carries no substantive change vs the state stored by a previous run', () => {
253
+ before(async () => {
254
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
255
+ await subject.recordTermsOutcome(makeOutcome());
256
+ await subject.completeRun();
257
+
258
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
259
+ await subject.recordTermsOutcome(makeOutcome()); // Identical
260
+ });
261
+
262
+ after(() => repository.removeAll());
263
+
264
+ it('does not commit the terms result again', async () => {
265
+ const termsCommits = await repository.git.listCommits([ '--', 'Facebook/Terms of Service.json' ]);
266
+
267
+ expect(termsCommits).to.have.lengthOf(1);
268
+ });
269
+
270
+ it('still counts the terms as processed and tracked', () => {
271
+ expect(subject.currentRun.coverage.processed).to.equal(1);
272
+ expect(subject.currentRun.tracked).to.deep.equal({ ok: 1, failed: 0 });
273
+ });
274
+
275
+ it('records no transition', () => {
276
+ expect(subject.currentRun.transitions).to.deep.equal({ newFailures: [], recoveries: [], reasonChanges: [] });
277
+ });
278
+ });
279
+ });
280
+
281
+ describe('#recordTermsOutcome trailers', () => {
282
+ let run;
283
+ let commit;
284
+
285
+ before(async () => {
286
+ run = await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
287
+ await subject.recordTermsOutcome(makeOutcome());
288
+ commit = await repository.git.getCommit([]);
289
+ });
290
+
291
+ after(() => repository.removeAll());
292
+
293
+ it('ties the per-terms commit to the current run through the x-run-id trailer', () => {
294
+ expect(commit.trailers['x-run-id']).to.equal(run.runId);
295
+ });
296
+ });
297
+
298
+ describe('#completeRun', () => {
299
+ context('when called before startRun', () => {
300
+ before(() => { subject.discardCurrentRun(); }); // Reset state left by an earlier describe
301
+
302
+ it('throws an error', async () => {
303
+ try {
304
+ await subject.completeRun();
305
+ expect.fail('expected completeRun to throw');
306
+ } catch (error) {
307
+ expect(error.message).to.match(/no tracking run in progress/);
308
+ }
309
+ });
310
+ });
311
+
312
+ context('after recording several outcomes', () => {
313
+ let runId;
314
+
315
+ before(async () => {
316
+ const run = await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 2 });
317
+
318
+ runId = run.runId;
319
+ await subject.recordTermsOutcome(makeOutcome({ termsType: 'Terms of Service' }));
320
+ await subject.recordTermsOutcome(makeOutcome({ termsType: 'Privacy Policy', status: 'failed', reasons: ['[fetch] HTTP 500'] }));
321
+ await subject.completeRun();
322
+ });
323
+
324
+ after(() => repository.removeAll());
325
+
326
+ it('computes tracked.ok and tracked.failed from the repository state', async () => {
327
+ const persisted = await repository.findLatestRun();
328
+
329
+ expect(persisted.tracked).to.deep.equal({ ok: 1, failed: 1 });
330
+ });
331
+
332
+ it('marks the run as completed', async () => {
333
+ const persisted = await repository.findLatestRun();
334
+
335
+ expect(persisted.lastRun.status).to.equal('completed');
336
+ expect(persisted.lastRun.endDate).to.be.a('string');
337
+ });
338
+
339
+ it('preserves the run identity', async () => {
340
+ const persisted = await repository.findLatestRun();
341
+
342
+ expect(persisted.runId).to.equal(runId);
343
+ });
344
+
345
+ it('clears currentRun so a new lifecycle can begin', () => {
346
+ expect(subject.currentRun).to.be.null;
347
+ });
348
+ });
349
+
350
+ context('when the run completion commit fails', () => {
351
+ let run;
352
+ let recoveredRun;
353
+
354
+ before(async () => {
355
+ run = await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 1 });
356
+ await subject.recordTermsOutcome(makeOutcome());
357
+
358
+ const commitStub = sinon.stub(repository.git, 'commit').rejects(new Error('Unable to create index.lock'));
359
+
360
+ await subject.completeRun().catch(() => {});
361
+ commitStub.restore();
362
+ subject.discardCurrentRun(); // The Archivist shuts the engine down on such a failure
363
+
364
+ const recorderOfNextBoot = new TrackingResultsRecorder({ repository, collectionId: COLLECTION_ID, schedule: SCHEDULE, engineVersion: ENGINE_VERSION });
365
+
366
+ recoveredRun = await recorderOfNextBoot.recoverCrashedRunIfAny({ getDeclaredTermsAtCommit: () => [{ serviceId: 'Facebook', termsType: 'Terms of Service' }] });
367
+ });
368
+
369
+ after(() => repository.removeAll());
370
+
371
+ it('leaves the run to be finalized as crashed by the next boot', () => {
372
+ expect(recoveredRun.runId).to.equal(run.runId);
373
+ expect(recoveredRun.lastRun.status).to.equal('crashed');
374
+ expect(recoveredRun.coverage).to.deep.equal({ processed: 1, skipped: [] });
375
+ });
376
+ });
377
+ });
378
+
379
+ describe('#recoverCrashedRunIfAny', () => {
380
+ context('when no in-progress run exists', () => {
381
+ it('returns null and does not invoke the declarations callback', async () => {
382
+ let callbackInvoked = false;
383
+ const getDeclaredTermsAtCommit = () => {
384
+ callbackInvoked = true;
385
+
386
+ return [];
387
+ };
388
+ const result = await subject.recoverCrashedRunIfAny({ getDeclaredTermsAtCommit });
389
+
390
+ expect(result).to.be.null;
391
+ expect(callbackInvoked).to.be.false;
392
+ });
393
+ });
394
+
395
+ context('when the latest run is completed', () => {
396
+ before(async () => {
397
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 0, termsCount: 0 });
398
+ await subject.completeRun();
399
+ });
400
+
401
+ after(() => repository.removeAll());
402
+
403
+ it('returns null', async () => {
404
+ expect(await subject.recoverCrashedRunIfAny({ getDeclaredTermsAtCommit: () => [] })).to.be.null;
405
+ });
406
+ });
407
+
408
+ context('when run.json is unreadable', () => {
409
+ before(() => fs.writeFile(path.join(REPOSITORY_PATH, 'run.json'), '{ corrupt'));
410
+
411
+ after(() => repository.removeAll());
412
+
413
+ it('throws an UnreadableRunError', async () => {
414
+ try {
415
+ await subject.recoverCrashedRunIfAny({ getDeclaredTermsAtCommit: () => [] });
416
+ } catch (error) {
417
+ expect(error).to.be.an.instanceOf(UnreadableRunError);
418
+
419
+ return;
420
+ }
421
+
422
+ expect.fail('No error was thrown');
423
+ });
424
+ });
425
+
426
+ context('when run.json cannot be read because of a system error', () => {
427
+ const systemError = Object.assign(new Error('EIO: i/o error, read'), { code: 'EIO' });
428
+
429
+ before(() => sinon.stub(repository, 'findLatestRun').rejects(systemError));
430
+
431
+ after(() => repository.findLatestRun.restore());
432
+
433
+ it('throws the original error, which is worth a retry', async () => {
434
+ const error = await subject.recoverCrashedRunIfAny({ getDeclaredTermsAtCommit: () => [] }).catch(error => error);
435
+
436
+ expect(error).to.equal(systemError);
437
+ });
438
+ });
439
+
440
+ context('when there is an in-progress run', () => {
441
+ let recoveredRun;
442
+ let savedRun;
443
+ let receivedSha;
444
+
445
+ before(async () => {
446
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 1, termsCount: 3 });
447
+ await subject.recordTermsOutcome(makeOutcome({ termsType: 'Terms of Service', transientErrorReasons: ['[fetch] HTTP code 503'] }));
448
+ await subject.recordTermsOutcome(makeOutcome({ termsType: 'Privacy Policy', status: 'failed', reasons: ['[fetch] HTTP code 503'] }));
449
+ subject.discardCurrentRun(); // Simulate crash before completeRun
450
+
451
+ const getDeclaredTermsAtCommit = sha => {
452
+ receivedSha = sha;
453
+
454
+ return [
455
+ { serviceId: 'Facebook', termsType: 'Terms of Service' },
456
+ { serviceId: 'Facebook', termsType: 'Privacy Policy' },
457
+ { serviceId: 'Facebook', termsType: 'Imprint' }, // Declared but never processed in the partial run
458
+ ];
459
+ };
460
+
461
+ recoveredRun = await subject.recoverCrashedRunIfAny({ getDeclaredTermsAtCommit });
462
+ savedRun = await repository.findLatestRun();
463
+ });
464
+
465
+ after(() => repository.removeAll());
466
+
467
+ it('returns the recovered run', () => {
468
+ expect(recoveredRun).to.be.instanceOf(Run);
469
+ expect(recoveredRun.lastRun.status).to.equal('crashed');
470
+ });
471
+
472
+ it('invokes the declarations callback with the previous run\'s declarations commit', () => {
473
+ expect(receivedSha).to.equal(DECLARATIONS_COMMIT);
474
+ });
475
+
476
+ it('persists the run as crashed', () => {
477
+ expect(savedRun.lastRun.status).to.equal('crashed');
478
+ });
479
+
480
+ it('sets the endDate', () => {
481
+ expect(savedRun.lastRun.endDate).to.be.a('string');
482
+ });
483
+
484
+ it('closes the run with an additive commit tied to it', async () => {
485
+ const runCommits = await repository.git.listCommits([ '--', 'run.json' ]);
486
+
487
+ expect(runCommits.map(commit => commit.message)).to.deep.equal([ `Start run ${savedRun.shortRunId}`, `Finalize crashed run ${savedRun.shortRunId}` ]);
488
+ expect(runCommits.map(commit => commit.trailers['x-run-id'])).to.deep.equal([ savedRun.runId, savedRun.runId ]);
489
+ });
490
+
491
+ it('counts processed terms from the partial run', () => {
492
+ expect(savedRun.coverage.processed).to.equal(2);
493
+ });
494
+
495
+ it('marks declared-but-not-committed terms as skipped for lack of a recorded outcome', () => {
496
+ expect(savedRun.coverage.skipped).to.deep.equal([
497
+ { serviceId: 'Facebook', termsType: 'Imprint', reason: 'no outcome recorded before the engine crashed' },
498
+ ]);
499
+ });
500
+
501
+ it('derives the tracked counts from the committed terms', () => {
502
+ expect(savedRun.tracked).to.deep.equal({ ok: 1, failed: 1 });
503
+ });
504
+
505
+ it('derives the transient errors count from the committed terms', () => {
506
+ expect(savedRun.transientErrors).to.equal(1);
507
+ });
508
+ });
509
+
510
+ context('when the crashed run had terms skipped from the start', () => {
511
+ let savedRun;
512
+
513
+ before(async () => {
514
+ await subject.startRun({
515
+ declarationsCommit: DECLARATIONS_COMMIT,
516
+ servicesCount: 1,
517
+ termsCount: 3,
518
+ skippedTerms: [{ serviceId: 'Facebook', termsType: 'Imprint', reason: 'not selected for this run' }],
519
+ });
520
+ await subject.recordTermsOutcome(makeOutcome({ termsType: 'Terms of Service' }));
521
+ subject.discardCurrentRun(); // Simulate crash before completeRun
522
+
523
+ await subject.recoverCrashedRunIfAny({
524
+ getDeclaredTermsAtCommit: () => [
525
+ { serviceId: 'Facebook', termsType: 'Terms of Service' },
526
+ { serviceId: 'Facebook', termsType: 'Privacy Policy' },
527
+ { serviceId: 'Facebook', termsType: 'Imprint' },
528
+ ],
529
+ });
530
+ savedRun = await repository.findLatestRun();
531
+ });
532
+
533
+ after(() => repository.removeAll());
534
+
535
+ it('preserves the persisted skips with their original reason', () => {
536
+ expect(savedRun.coverage.skipped).to.deep.include({ serviceId: 'Facebook', termsType: 'Imprint', reason: 'not selected for this run' });
537
+ });
538
+
539
+ it('attributes only the unaccounted terms to the crash', () => {
540
+ expect(savedRun.coverage.skipped).to.deep.include({ serviceId: 'Facebook', termsType: 'Privacy Policy', reason: 'no outcome recorded before the engine crashed' });
541
+ expect(savedRun.coverage.skipped).to.have.lengthOf(2);
542
+ });
543
+ });
544
+ });
545
+
546
+ describe('#discardCurrentRun', () => {
547
+ it('clears currentRun without persisting anything', async () => {
548
+ await subject.startRun({ declarationsCommit: DECLARATIONS_COMMIT, servicesCount: 0, termsCount: 0 });
549
+ expect(subject.currentRun).to.not.be.null;
550
+ subject.discardCurrentRun();
551
+ expect(subject.currentRun).to.be.null;
552
+ await repository.removeAll();
553
+ });
554
+ });
555
+
556
+ describe('#finalize', () => {
557
+ it('delegates to the repository without throwing', () => subject.finalize());
558
+ });
559
+ });
@@ -0,0 +1,136 @@
1
+ import fsApi from 'fs';
2
+ import path from 'path';
3
+
4
+ import Git from '../../git/index.js';
5
+
6
+ import * as RunMapper from './run/dataMapper.js';
7
+ import * as TermsResultMapper from './terms-result/dataMapper.js';
8
+
9
+ const fs = fsApi.promises;
10
+
11
+ export default class TrackingResultsRepository {
12
+ constructor({ path: repositoryPath, author, publish }) {
13
+ this.path = path.resolve(process.cwd(), repositoryPath); // Same resolution as RepositoryFactory: configured storage paths are project-relative and must not depend on the cwd of downstream git processes
14
+ this.needsPublication = publish;
15
+ this.git = new Git({ path: this.path, author });
16
+ }
17
+
18
+ async initialize() {
19
+ await this.git.initialize();
20
+ await this.git.cleanUp(); // Drop any uncommitted leftovers that would otherwise pollute the next commit
21
+ await this.git.writeCommitGraph(); // Keep the commit graph in sync with the existing history for fast log operations
22
+
23
+ return this;
24
+ }
25
+
26
+ async finalize() {
27
+ if (this.needsPublication) {
28
+ await this.git.pushChanges();
29
+ }
30
+
31
+ return this.git.updateCommitGraph();
32
+ }
33
+
34
+ removeAll() { // Test-only: destroys all history
35
+ return this.git.destroyHistory();
36
+ }
37
+
38
+ async saveTermsResult(newResult, { trailers = {} } = {}) { // Trailers carry run-scoped context (e.g. x-run-id); they are not TermsResult state, so they are passed alongside rather than through the mapper
39
+ newResult.validate();
40
+
41
+ const { serviceId, termsType } = newResult;
42
+ const absolutePath = path.join(this.path, TermsResultMapper.generateFilePath(serviceId, termsType));
43
+ const previousContent = await readFile(absolutePath);
44
+ const previousResult = previousContent === null ? null : TermsResultMapper.toDomain({ serviceId, termsType, data: parseJson(previousContent, absolutePath) });
45
+ const persistence = TermsResultMapper.toPersistence(newResult, previousResult);
46
+
47
+ if (!persistence) { // No substantive change between previous and new state, nothing to commit
48
+ return { sha: null, eventType: null };
49
+ }
50
+
51
+ const sha = await this.commit({ ...persistence, trailers, previousContent }); // The content read for the comparison doubles as the rollback backup, sparing a second read
52
+
53
+ return { sha, eventType: persistence.eventType };
54
+ }
55
+
56
+ saveRun(run, { trailers = {} } = {}) {
57
+ run.validate();
58
+
59
+ return this.commit({ ...RunMapper.toPersistence(run), trailers });
60
+ }
61
+
62
+ async findLatestTermsResult(serviceId, termsType) {
63
+ const data = await readJsonFile(path.join(this.path, TermsResultMapper.generateFilePath(serviceId, termsType)));
64
+
65
+ return data === null ? null : TermsResultMapper.toDomain({ serviceId, termsType, data });
66
+ }
67
+
68
+ async findLatestRun() {
69
+ const data = await readJsonFile(path.join(this.path, RunMapper.FILE_NAME));
70
+
71
+ return data === null ? null : RunMapper.toDomain(data);
72
+ }
73
+
74
+ async findLatestRunCommitSha() { // Returns the SHA of the latest commit that touched run.json; when the latest run is in_progress, this is the run-start commit and is used as the reference point for crash recovery
75
+ const [commit] = await this.git.listCommits([ '--', RunMapper.FILE_NAME ], { reverse: false, maxCount: 1 });
76
+
77
+ return commit?.hash ?? null;
78
+ }
79
+
80
+ async findCommittedTermsResultsSince(sha) { // Lists each distinct (serviceId, termsType) pair that had at least one per-terms commit between `sha` (exclusive) and HEAD (inclusive)
81
+ if (!sha) {
82
+ return [];
83
+ }
84
+
85
+ const commits = await this.git.listCommits([ `${sha}..HEAD`, '--', '*/*.json' ], { reverse: false }); // The pathspec excludes root-level files such as run.json
86
+ const files = new Set(commits.flatMap(commit => commit.diff?.files.map(({ file }) => file) ?? []).filter(file => file.endsWith('.json'))); // Listed names are never C-quoted by git: every file here was written by saveTermsResult, whose validation rejects each character git quotes (double quote, backslash, control characters)
87
+
88
+ return [...files].map(file => ({ serviceId: path.posix.dirname(file), termsType: path.posix.basename(file, '.json') }));
89
+ }
90
+
91
+ async commit({ filePath: relativePath, content, message, date, trailers, previousContent }) {
92
+ const absolutePath = path.join(this.path, relativePath);
93
+ const backupContent = previousContent === undefined ? await readFile(absolutePath) : previousContent; // Read only when the caller does not already hold the previous content; null means the file is known to be absent
94
+
95
+ try {
96
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
97
+ await fs.writeFile(absolutePath, content);
98
+ await this.git.add(absolutePath);
99
+
100
+ return await this.git.commit({ filePath: absolutePath, message, date, trailers });
101
+ } catch (error) {
102
+ await (backupContent === null ? fs.rm(absolutePath, { force: true }) : fs.writeFile(absolutePath, backupContent)).catch(() => {}); // Readers rely on the working tree matching HEAD, so a content that was not committed must not stay in it. Restored through the file system as git may be the very cause of the failure; best effort, as the next initialization cleans up anyway
103
+ throw new Error(`Could not commit "${relativePath}" with message "${message}": ${error.message}`, { cause: error }); // Preserve the original stack via `cause` so operators can trace back to the underlying simple-git or fs error
104
+ }
105
+ }
106
+ }
107
+
108
+ async function readFile(absolutePath) { // Resolves to null when the file does not exist, while other I/O failures are propagated
109
+ try {
110
+ return await fs.readFile(absolutePath, 'utf8');
111
+ } catch (error) {
112
+ if (error.code === 'ENOENT') {
113
+ return null;
114
+ }
115
+
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ async function readJsonFile(absolutePath) { // Resolves to null when the file does not exist, while parse errors are propagated
121
+ const content = await readFile(absolutePath);
122
+
123
+ if (content === null) {
124
+ return null;
125
+ }
126
+
127
+ return parseJson(content, absolutePath);
128
+ }
129
+
130
+ function parseJson(content, absolutePath) {
131
+ try {
132
+ return JSON.parse(content);
133
+ } catch (error) {
134
+ throw new Error(`Could not parse JSON in "${absolutePath}": ${error.message}`, { cause: error });
135
+ }
136
+ }