@opentermsarchive/engine 16.0.1 → 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/dataset/export/index.js +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/collection/index.test.js +2 -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/factory.js +3 -2
- package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
- package/src/archivist/recorder/repositories/git/index.js +30 -33
- package/src/archivist/recorder/repositories/git/index.test.js +98 -2
- package/src/archivist/recorder/repositories/mongo/index.js +14 -3
- package/src/archivist/recorder/repositories/mongo/index.test.js +27 -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/collection-api/routes/index.js +2 -2
- package/src/git/errors.js +1 -0
- package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +79 -23
- 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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import events from 'events';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
|
|
4
|
+
import config from 'config';
|
|
5
|
+
import mime from 'mime';
|
|
6
|
+
|
|
7
|
+
import { GitObjectNotFoundError } from '../../git/index.js';
|
|
8
|
+
import { getCollection } from '../collection/index.js';
|
|
9
|
+
import { ExtractDocumentError } from '../extract/index.js';
|
|
10
|
+
import { FetchDocumentError } from '../fetcher/index.js';
|
|
11
|
+
import * as declaredServices from '../services/index.js';
|
|
12
|
+
import Service from '../services/service.js';
|
|
13
|
+
|
|
14
|
+
import { MissingCollectionIdError, UnreadableRunError } from './errors.js';
|
|
15
|
+
import TrackingResultsRecorder from './recorder.js';
|
|
16
|
+
import TrackingResultsRepository from './repository.js';
|
|
17
|
+
import { STATUSES } from './terms-result/index.js';
|
|
18
|
+
|
|
19
|
+
export { MissingCollectionIdError } from './errors.js';
|
|
20
|
+
export { RUN_ID_TRAILER_KEY } from './recorder.js';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { version: PACKAGE_VERSION } = require('../../../package.json');
|
|
24
|
+
|
|
25
|
+
export default class TrackingResults extends events.EventEmitter {
|
|
26
|
+
static async create(trackingResultsConfig) { // Resolves the engine-side wiring (collection identity, schedule, engine version) so the caller does not need to know it
|
|
27
|
+
if (trackingResultsConfig.storage.type !== 'git') { // Git is the only supported backend, as the audit trail relies on its tamper-evident properties
|
|
28
|
+
throw new Error(`Unsupported tracking-results storage type "${trackingResultsConfig.storage.type}"; only "git" is supported`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const collection = await getCollection();
|
|
32
|
+
const collectionId = collection.metadata?.id; // getCollection always resolves to a Collection instance; metadata stays undefined when the metadata file is absent, which is exactly the missing-id case reported below
|
|
33
|
+
|
|
34
|
+
if (!collectionId) {
|
|
35
|
+
throw new MissingCollectionIdError('Collection metadata "id" is required to record tracking-results, as it identifies the collection in every persisted run. Add an "id" field to the collection metadata file.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const repository = new TrackingResultsRepository(trackingResultsConfig.storage.git);
|
|
39
|
+
const recorder = new TrackingResultsRecorder({
|
|
40
|
+
repository,
|
|
41
|
+
collectionId,
|
|
42
|
+
schedule: config.get('@opentermsarchive/engine.trackingSchedule'),
|
|
43
|
+
engineVersion: PACKAGE_VERSION,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return new TrackingResults({ recorder });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
constructor({ recorder }) {
|
|
50
|
+
super();
|
|
51
|
+
this.recorder = recorder;
|
|
52
|
+
this.ready = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async initialize() {
|
|
56
|
+
await this.ensureReady(); // Attempted as early as possible so readers of the repository see the previous run finalized without waiting for the next tracking run
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async finalize() {
|
|
60
|
+
if (!this.ready) { // Nothing was recorded, and the repository may not even be initialized
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await this.recorder.finalize();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
this.emit('warn', { message: `Could not finalize the tracking-results repository: ${error.message}; recorded commits are kept locally and the finalization will be retried at the next run` }); // Everything is already committed locally, so a failed push or commit-graph update loses nothing: the next successful finalize pushes all accumulated commits
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get hasRunInProgress() {
|
|
72
|
+
return Boolean(this.recorder.currentRun);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get currentRunId() {
|
|
76
|
+
return this.recorder.currentRun?.runId ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getDeclarationsCommit() { // Meant to be called right before the declarations are loaded, so that the commit identifies the declarations applied by every run of this process, whatever happens to their repository afterwards
|
|
80
|
+
try {
|
|
81
|
+
const declarationsCommit = await declaredServices.getDeclarationsCommit();
|
|
82
|
+
|
|
83
|
+
if (!declarationsCommit) { // The audit trail premise (tamper-evident declarations commit) cannot be honoured
|
|
84
|
+
this.emit('warn', { message: 'The declarations directory is not a Git repository; tracking-results runs will not be recorded' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return declarationsCommit;
|
|
88
|
+
} catch (error) { // An actual git failure, distinct from "declarations is not a Git repository" which is reported as null
|
|
89
|
+
this.emit('warn', { message: `Could not read the declarations commit: ${error.message}; tracking-results runs will not be recorded` });
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async startRun({ services, declarationsCommit, selectedServicesIds, selectedTermsTypes }) {
|
|
96
|
+
if (!await this.ensureReady()) { // A new Start run commit would hide the crashed run's reference SHA and make its recovery impossible forever
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!declarationsCommit) { // Already reported by getDeclarationsCommit
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const servicesIds = Object.keys(services).sort((a, b) => a.localeCompare(b)); // Sorted so the persisted skipped list is deterministic
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await this.recorder.startRun({
|
|
108
|
+
declarationsCommit,
|
|
109
|
+
servicesCount: servicesIds.length, // Declared counts always cover the full declarations, whatever subset this run processes; coverage.skipped carries the difference so the coverage proof (declared = processed + skipped) stays derivable
|
|
110
|
+
termsCount: Service.getNumberOfTerms(services, servicesIds, []),
|
|
111
|
+
skippedTerms: unselectedTerms(services, servicesIds, { selectedServicesIds, selectedTermsTypes }),
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.emit('warn', { message: `Could not start the tracking-results run: ${error.message}; tracking-results is disabled for this run` }); // Like every other tracking-results failure, a failed run-start commit degrades the audit trail, never the tracking itself
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
recordSuccess(terms, { transientErrors } = {}) {
|
|
119
|
+
return this.record(terms, { status: STATUSES.ok, transientErrorReasons: transientErrors?.length ? categorizeReasons(transientErrors) : undefined });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
recordFailure(terms, errors) {
|
|
123
|
+
return this.record(terms, { status: STATUSES.failed, reasons: categorizeReasons(errors) });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async record(terms, { status, reasons, transientErrorReasons }) {
|
|
127
|
+
if (!this.hasRunInProgress) { // No tracking-results run is active (technical upgrades, run start skipped or failed); nothing to record
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await this.recorder.recordTermsOutcome({
|
|
132
|
+
serviceId: terms.service.id,
|
|
133
|
+
termsType: terms.type,
|
|
134
|
+
serviceName: terms.service.name,
|
|
135
|
+
sourceDocuments: terms.sourceDocuments.map(sourceDocument => ({
|
|
136
|
+
id: sourceDocument.id,
|
|
137
|
+
...sourceDocument.toPersistence(),
|
|
138
|
+
mimeType: normalizeMimeType(sourceDocument.mimeType),
|
|
139
|
+
snapshotId: sourceDocument.snapshotId ?? null,
|
|
140
|
+
})),
|
|
141
|
+
status,
|
|
142
|
+
reasons,
|
|
143
|
+
transientErrorReasons,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
completeRun() {
|
|
148
|
+
return this.recorder.completeRun();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async ensureReady() { // Returns true when it is safe to write a new run-start commit: the repository is initialized and a pending in_progress run.json has been finalized. Success is memoised, and a failure is retried at the next call so a long-lived scheduled process self-heals without a restart
|
|
152
|
+
if (this.ready) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await this.recorder.initialize(); // Repeated at each attempt, as it also drops what a failed attempt may have left uncommitted
|
|
158
|
+
|
|
159
|
+
const recovered = await this.recorder.recoverCrashedRunIfAny({ getDeclaredTermsAtCommit: commit => this.declaredTermsAtCommit(commit) });
|
|
160
|
+
|
|
161
|
+
this.ready = true;
|
|
162
|
+
|
|
163
|
+
if (recovered) {
|
|
164
|
+
this.emit('warn', { message: `Recovered crashed run ${recovered.shortRunId}: persisted ${recovered.coverage.processed} processed terms and ${recovered.coverage.skipped.length} skipped terms before the new run starts` });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return true;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof UnreadableRunError) { // A stale or incompatible run.json cannot be recovered by construction: proceed, the next startRun will overwrite it with a valid one
|
|
170
|
+
this.ready = true;
|
|
171
|
+
this.emit('warn', { message: `Could not read previous tracking-results state: ${error.message}. The previous run.json may be from an incompatible engine version; it will be overwritten by the next run.` });
|
|
172
|
+
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.emit('warn', { message: `Could not prepare the tracking-results repository: ${error.message}; tracking-results is disabled for this run and the preparation will be retried at the next one. If this warning persists across runs, inspect or delete the repository at "${this.recorder.repository.path}" so it can be recreated` }); // An auxiliary audit trail must not prevent tracking itself, be it at initialization
|
|
177
|
+
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async declaredTermsAtCommit(commit) {
|
|
183
|
+
try {
|
|
184
|
+
return await declaredServices.getDeclaredTermsAtCommit(commit);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof GitObjectNotFoundError) { // The commit is unreachable forever (shallow clone, rewritten declarations history): fall back to the currently declared terms so the crashed run can still be finalized, and surface the approximation
|
|
187
|
+
this.emit('warn', { message: `Declarations commit ${commit} is not reachable; crash recovery coverage falls back to the currently declared terms` });
|
|
188
|
+
|
|
189
|
+
return declaredServices.getDeclaredTerms();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeMimeType(mimeType) { // Fetchers report the raw Content-Type header while Git snapshots report the type derived from their file extension; aligned on the latter, so that a parameter or an alias is not recorded as a MIME type change
|
|
198
|
+
if (!mimeType) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return mime.getType(mime.getExtension(mimeType) || '') || mimeType.split(';')[0].trim().toLowerCase();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function categorizeReasons(errors) { // Tags each reason with [fetch], [extraction] or [internal] so that commit subjects and consumers of the persisted reasons can split by category without sniffing error types
|
|
206
|
+
return errors.map(error => {
|
|
207
|
+
if (error instanceof FetchDocumentError) {
|
|
208
|
+
return `[fetch] ${error.message}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (error instanceof ExtractDocumentError) {
|
|
212
|
+
return `[extraction] ${error.message}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return '[internal] Unexpected engine error'; // The message of an unexpected error may expose server paths or git output, which must not enter a published history that cannot be rewritten; the details are in the logs
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function unselectedTerms(services, servicesIds, { selectedServicesIds, selectedTermsTypes }) { // Enumerates the declared terms that this run will not process, so partial runs (CLI-filtered services or terms types) record them as explicitly skipped
|
|
220
|
+
const skipped = [];
|
|
221
|
+
|
|
222
|
+
for (const serviceId of servicesIds) {
|
|
223
|
+
const selectedTypes = new Set(selectedServicesIds.includes(serviceId) ? services[serviceId].getTermsTypes(selectedTermsTypes) : []);
|
|
224
|
+
|
|
225
|
+
for (const termsType of services[serviceId].getTermsTypes()) {
|
|
226
|
+
if (!selectedTypes.has(termsType)) {
|
|
227
|
+
skipped.push({ serviceId, termsType, reason: 'not selected for this run' });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return skipped;
|
|
233
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { expect, use } from 'chai';
|
|
2
|
+
import sinon from 'sinon';
|
|
3
|
+
import sinonChai from 'sinon-chai';
|
|
4
|
+
|
|
5
|
+
import Git from '../../git/index.js';
|
|
6
|
+
import { ExtractDocumentError } from '../extract/index.js';
|
|
7
|
+
import { FetchDocumentError } from '../fetcher/index.js';
|
|
8
|
+
import { getDeclaredTerms } from '../services/index.js';
|
|
9
|
+
import SourceDocument from '../services/sourceDocument.js';
|
|
10
|
+
|
|
11
|
+
import { UnreadableRunError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
import TrackingResults from './index.js';
|
|
14
|
+
|
|
15
|
+
use(sinonChai);
|
|
16
|
+
|
|
17
|
+
const DECLARATIONS_COMMIT = 'c0ffee1234567890c0ffee1234567890c0ffee12';
|
|
18
|
+
|
|
19
|
+
function makeRecorder(overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
currentRun: null,
|
|
22
|
+
repository: { path: '/data/tracking-results' },
|
|
23
|
+
initialize: sinon.stub().resolves(),
|
|
24
|
+
finalize: sinon.stub().resolves(),
|
|
25
|
+
startRun: sinon.stub().resolves(),
|
|
26
|
+
recordTermsOutcome: sinon.stub().resolves(),
|
|
27
|
+
completeRun: sinon.stub().resolves(),
|
|
28
|
+
recoverCrashedRunIfAny: sinon.stub().resolves(null),
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeServices() { // Minimal stand-ins for the loaded services map, exposing the getTermsTypes(filter) and getNumberOfTerms(filter) contracts used by the run start
|
|
34
|
+
const service = termsTypes => {
|
|
35
|
+
const getTermsTypes = filter => (filter?.length ? termsTypes.filter(type => filter.includes(type)) : termsTypes);
|
|
36
|
+
|
|
37
|
+
return { getTermsTypes, getNumberOfTerms: filter => getTermsTypes(filter).length };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
'Service A': service([ 'Terms of Service', 'Privacy Policy' ]),
|
|
42
|
+
'Service B': service(['Terms of Service']),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeTerms() {
|
|
47
|
+
const sourceDocument = new SourceDocument({ location: 'https://example.com/terms', contentSelectors: 'body' });
|
|
48
|
+
|
|
49
|
+
sourceDocument.mimeType = 'text/html';
|
|
50
|
+
sourceDocument.snapshotId = 'abc123';
|
|
51
|
+
|
|
52
|
+
return { service: { id: 'Service A', name: 'Service A' }, type: 'Terms of Service', sourceDocuments: [sourceDocument] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('TrackingResults', () => {
|
|
56
|
+
let recorder;
|
|
57
|
+
let subject;
|
|
58
|
+
let warnings;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
recorder = makeRecorder();
|
|
62
|
+
subject = new TrackingResults({ recorder });
|
|
63
|
+
warnings = [];
|
|
64
|
+
subject.on('warn', ({ message }) => warnings.push(message));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('.create', () => {
|
|
68
|
+
context('when the configured storage type is not git', () => {
|
|
69
|
+
it('throws an error stating the supported type', async () => {
|
|
70
|
+
const error = await TrackingResults.create({ storage: { type: 'mongo', mongo: {} } }).catch(error => error);
|
|
71
|
+
|
|
72
|
+
expect(error.message).to.include('only "git" is supported');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('#initialize', () => {
|
|
78
|
+
it('initialises the recorder and attempts crash recovery', async () => {
|
|
79
|
+
await subject.initialize();
|
|
80
|
+
|
|
81
|
+
expect(recorder.initialize).to.have.been.calledOnce;
|
|
82
|
+
expect(recorder.recoverCrashedRunIfAny).to.have.been.calledOnce;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
context('when a crashed run has been recovered', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
recorder.recoverCrashedRunIfAny.resolves({ shortRunId: 'ota-run-f47ac10b', coverage: { processed: 2, skipped: [{ serviceId: 'Service B', termsType: 'Terms of Service', reason: 'no outcome recorded before the engine crashed' }] } });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('announces it', async () => {
|
|
91
|
+
await subject.initialize();
|
|
92
|
+
|
|
93
|
+
expect(warnings.join('\n')).to.include('Recovered crashed run ota-run-f47ac10b: persisted 2 processed terms and 1 skipped terms');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
context('when the declarations commit of the crashed run is not reachable', () => {
|
|
98
|
+
let declaredTerms;
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
recorder.recoverCrashedRunIfAny.callsFake(async ({ getDeclaredTermsAtCommit }) => {
|
|
102
|
+
declaredTerms = await getDeclaredTermsAtCommit('deadbeef'.repeat(5)); // Well-formed but unknown commit
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('falls back to the currently declared terms and reports the approximation', async () => {
|
|
109
|
+
await subject.initialize();
|
|
110
|
+
|
|
111
|
+
expect(declaredTerms).to.have.deep.members(await getDeclaredTerms());
|
|
112
|
+
expect(warnings.join('\n')).to.include('is not reachable');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
context('when the repository cannot be initialized', () => {
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
recorder.initialize.onFirstCall().rejects(new Error('Unable to create index.lock'));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('degrades with a warning instead of failing the engine initialization', async () => {
|
|
122
|
+
await subject.initialize();
|
|
123
|
+
|
|
124
|
+
expect(recorder.recoverCrashedRunIfAny).to.not.have.been.called;
|
|
125
|
+
expect(warnings.join('\n')).to.include('Unable to create index.lock');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('points operators to the repository to inspect if the warning persists', async () => {
|
|
129
|
+
await subject.initialize();
|
|
130
|
+
|
|
131
|
+
expect(warnings.join('\n')).to.include('inspect or delete the repository at "/data/tracking-results"');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('retries the initialization at the next run', async () => {
|
|
135
|
+
await subject.initialize();
|
|
136
|
+
await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
137
|
+
|
|
138
|
+
expect(recorder.initialize).to.have.been.calledTwice;
|
|
139
|
+
expect(recorder.startRun).to.have.been.calledOnce;
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('#getDeclarationsCommit', () => {
|
|
145
|
+
afterEach(() => Git.getHeadSha.restore());
|
|
146
|
+
|
|
147
|
+
it('resolves to the commit of the declarations repository', async () => {
|
|
148
|
+
sinon.stub(Git, 'getHeadSha').resolves(DECLARATIONS_COMMIT);
|
|
149
|
+
|
|
150
|
+
expect(await subject.getDeclarationsCommit()).to.equal(DECLARATIONS_COMMIT);
|
|
151
|
+
expect(warnings).to.be.empty;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
context('when the declarations are not versioned', () => {
|
|
155
|
+
it('resolves to null and reports it', async () => {
|
|
156
|
+
sinon.stub(Git, 'getHeadSha').resolves(null);
|
|
157
|
+
|
|
158
|
+
expect(await subject.getDeclarationsCommit()).to.be.null;
|
|
159
|
+
expect(warnings.join('\n')).to.include('is not a Git repository');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
context('when the commit cannot be read', () => {
|
|
164
|
+
it('resolves to null and reports it', async () => {
|
|
165
|
+
sinon.stub(Git, 'getHeadSha').rejects(new Error('git failure'));
|
|
166
|
+
|
|
167
|
+
expect(await subject.getDeclarationsCommit()).to.be.null;
|
|
168
|
+
expect(warnings.join('\n')).to.include('Could not read the declarations commit: git failure');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('#startRun', () => {
|
|
174
|
+
it('starts a recorder run with the full declared counts and the unselected terms as skips', async () => {
|
|
175
|
+
const services = makeServices();
|
|
176
|
+
|
|
177
|
+
await subject.startRun({ services, declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
178
|
+
|
|
179
|
+
expect(recorder.startRun).to.have.been.calledOnce;
|
|
180
|
+
|
|
181
|
+
const { declarationsCommit, servicesCount, termsCount, skippedTerms } = recorder.startRun.firstCall.args[0];
|
|
182
|
+
|
|
183
|
+
expect(declarationsCommit).to.equal(DECLARATIONS_COMMIT);
|
|
184
|
+
expect(servicesCount).to.equal(2);
|
|
185
|
+
expect(termsCount).to.equal(3);
|
|
186
|
+
expect(skippedTerms).to.deep.equal([{ serviceId: 'Service B', termsType: 'Terms of Service', reason: 'not selected for this run' }]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('narrows the skips to the selected terms types', async () => {
|
|
190
|
+
const services = makeServices();
|
|
191
|
+
|
|
192
|
+
await subject.startRun({ services, declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: [ 'Service A', 'Service B' ], selectedTermsTypes: ['Privacy Policy'] });
|
|
193
|
+
|
|
194
|
+
const { skippedTerms } = recorder.startRun.firstCall.args[0];
|
|
195
|
+
|
|
196
|
+
expect(skippedTerms).to.deep.equal([
|
|
197
|
+
{ serviceId: 'Service A', termsType: 'Terms of Service', reason: 'not selected for this run' },
|
|
198
|
+
{ serviceId: 'Service B', termsType: 'Terms of Service', reason: 'not selected for this run' },
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
context('when the declarations commit is unknown', () => {
|
|
203
|
+
it('does not start a recorder run', async () => {
|
|
204
|
+
await subject.startRun({ services: makeServices(), declarationsCommit: null, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
205
|
+
|
|
206
|
+
expect(recorder.startRun).to.not.have.been.called;
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
context('when the recorder cannot start the run', () => {
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
recorder.startRun.rejects(new Error('Run start commit failed'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('degrades with a warning instead of failing the tracking run', async () => {
|
|
216
|
+
await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
217
|
+
|
|
218
|
+
expect(subject.hasRunInProgress).to.be.false;
|
|
219
|
+
expect(warnings.join('\n')).to.include('tracking-results is disabled for this run');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
context('when crash recovery keeps failing', () => {
|
|
224
|
+
beforeEach(() => {
|
|
225
|
+
recorder.recoverCrashedRunIfAny.rejects(new Error('transient git failure'));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('does not start a recorder run', async () => { // A new Start run commit would hide the crashed run's reference SHA and make its recovery impossible forever
|
|
229
|
+
await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
230
|
+
|
|
231
|
+
expect(recorder.startRun).to.not.have.been.called;
|
|
232
|
+
expect(warnings.join('\n')).to.include('will be retried at the next one');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('retries the recovery at the next run', async () => {
|
|
236
|
+
await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
237
|
+
await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
238
|
+
|
|
239
|
+
expect(recorder.recoverCrashedRunIfAny).to.have.been.calledTwice;
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
context('when the previous run.json is unreadable', () => {
|
|
244
|
+
beforeEach(() => {
|
|
245
|
+
recorder.recoverCrashedRunIfAny.rejects(new UnreadableRunError('Could not parse JSON'));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('proceeds and announces the file will be overwritten', async () => {
|
|
249
|
+
await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
250
|
+
|
|
251
|
+
expect(recorder.startRun).to.have.been.calledOnce;
|
|
252
|
+
expect(warnings.join('\n')).to.include('it will be overwritten by the next run');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
context('when recovery already succeeded', () => {
|
|
257
|
+
it('does not attempt it again', async () => {
|
|
258
|
+
await subject.initialize();
|
|
259
|
+
await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
|
|
260
|
+
|
|
261
|
+
expect(recorder.recoverCrashedRunIfAny).to.have.been.calledOnce;
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('#recordSuccess', () => {
|
|
267
|
+
context('when a run is in progress', () => {
|
|
268
|
+
beforeEach(() => {
|
|
269
|
+
recorder.currentRun = {};
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('maps the terms to the recorder payload', async () => {
|
|
273
|
+
await subject.recordSuccess(makeTerms());
|
|
274
|
+
|
|
275
|
+
expect(recorder.recordTermsOutcome).to.have.been.calledOnce;
|
|
276
|
+
expect(recorder.recordTermsOutcome.firstCall.args[0]).to.deep.equal({
|
|
277
|
+
serviceId: 'Service A',
|
|
278
|
+
termsType: 'Terms of Service',
|
|
279
|
+
serviceName: 'Service A',
|
|
280
|
+
sourceDocuments: [{
|
|
281
|
+
id: 'terms',
|
|
282
|
+
fetch: 'https://example.com/terms',
|
|
283
|
+
select: 'body',
|
|
284
|
+
remove: undefined,
|
|
285
|
+
filter: undefined,
|
|
286
|
+
executeClientScripts: undefined,
|
|
287
|
+
mimeType: 'text/html',
|
|
288
|
+
snapshotId: 'abc123',
|
|
289
|
+
}],
|
|
290
|
+
status: 'ok',
|
|
291
|
+
reasons: undefined,
|
|
292
|
+
transientErrorReasons: undefined,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('normalises the MIME type reported by the fetcher', async () => {
|
|
297
|
+
const terms = makeTerms();
|
|
298
|
+
|
|
299
|
+
terms.sourceDocuments[0].mimeType = 'text/html; charset=utf-8';
|
|
300
|
+
await subject.recordSuccess(terms);
|
|
301
|
+
|
|
302
|
+
expect(recorder.recordTermsOutcome.firstCall.args[0].sourceDocuments[0].mimeType).to.equal('text/html');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('records a missing MIME type as null', async () => {
|
|
306
|
+
const terms = makeTerms();
|
|
307
|
+
|
|
308
|
+
terms.sourceDocuments[0].mimeType = undefined;
|
|
309
|
+
await subject.recordSuccess(terms);
|
|
310
|
+
|
|
311
|
+
expect(recorder.recordTermsOutcome.firstCall.args[0].sourceDocuments[0].mimeType).to.be.null;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('categorises the transient errors', async () => {
|
|
315
|
+
await subject.recordSuccess(makeTerms(), { transientErrors: [new FetchDocumentError('HTTP code 503')] });
|
|
316
|
+
|
|
317
|
+
expect(recorder.recordTermsOutcome.firstCall.args[0].transientErrorReasons).to.deep.equal(['[fetch] Fetch failed: HTTP code 503']);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
context('when no run is in progress', () => {
|
|
322
|
+
it('records nothing', async () => {
|
|
323
|
+
await subject.recordSuccess(makeTerms());
|
|
324
|
+
|
|
325
|
+
expect(recorder.recordTermsOutcome).to.not.have.been.called;
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('#recordFailure', () => {
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
recorder.currentRun = {};
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('categorises each error by origin', async () => {
|
|
336
|
+
await subject.recordFailure(makeTerms(), [
|
|
337
|
+
new FetchDocumentError('HTTP code 500'),
|
|
338
|
+
new ExtractDocumentError('CSS selector has no match'),
|
|
339
|
+
new Error('Could not commit /home/ota/data/snapshots/Service/Terms.html'),
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
expect(recorder.recordTermsOutcome.firstCall.args[0].status).to.equal('failed');
|
|
343
|
+
expect(recorder.recordTermsOutcome.firstCall.args[0].reasons).to.deep.equal([
|
|
344
|
+
'[fetch] Fetch failed: HTTP code 500',
|
|
345
|
+
'[extraction] Extract failed: CSS selector has no match',
|
|
346
|
+
'[internal] Unexpected engine error',
|
|
347
|
+
]);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('#completeRun and #finalize', () => {
|
|
352
|
+
it('delegate to the recorder', async () => {
|
|
353
|
+
await subject.initialize();
|
|
354
|
+
await subject.completeRun();
|
|
355
|
+
await subject.finalize();
|
|
356
|
+
|
|
357
|
+
expect(recorder.completeRun).to.have.been.calledOnce;
|
|
358
|
+
expect(recorder.finalize).to.have.been.calledOnce;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
context('when the repository could not be initialized', () => {
|
|
362
|
+
beforeEach(() => {
|
|
363
|
+
recorder.initialize.rejects(new Error('Unable to create index.lock'));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('does not finalize it', async () => {
|
|
367
|
+
await subject.initialize();
|
|
368
|
+
await subject.finalize();
|
|
369
|
+
|
|
370
|
+
expect(recorder.finalize).to.not.have.been.called;
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
context('when the push fails at finalize', () => {
|
|
375
|
+
beforeEach(() => {
|
|
376
|
+
recorder.finalize.rejects(new Error('remote rejected'));
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('degrades with a warning, as the commits remain local', async () => {
|
|
380
|
+
await subject.initialize();
|
|
381
|
+
await subject.finalize();
|
|
382
|
+
|
|
383
|
+
expect(warnings.join('\n')).to.include('recorded commits are kept locally');
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('#hasRunInProgress', () => {
|
|
389
|
+
it('reflects the recorder state', () => {
|
|
390
|
+
expect(subject.hasRunInProgress).to.be.false;
|
|
391
|
+
recorder.currentRun = {};
|
|
392
|
+
expect(subject.hasRunInProgress).to.be.true;
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe('#currentRunId', () => {
|
|
397
|
+
it('returns null when no run is in progress', () => {
|
|
398
|
+
expect(subject.currentRunId).to.be.null;
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('returns the id of the run in progress', () => {
|
|
402
|
+
recorder.currentRun = { runId: 'ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479' };
|
|
403
|
+
expect(subject.currentRunId).to.equal('ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479');
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
});
|