@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.
Files changed (52) 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/dataset/export/index.js +1 -1
  5. package/scripts/declarations/validate/index.mocha.js +7 -0
  6. package/scripts/import/index.js +1 -1
  7. package/scripts/import/loadCommits.js +1 -1
  8. package/scripts/rewrite/initializer/index.js +1 -1
  9. package/scripts/rewrite/rewrite-snapshots.js +1 -1
  10. package/scripts/rewrite/rewrite-versions.js +1 -1
  11. package/src/archivist/collection/index.test.js +2 -1
  12. package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
  13. package/src/archivist/fetcher/index.test.js +12 -0
  14. package/src/archivist/index.js +106 -23
  15. package/src/archivist/index.test.js +659 -9
  16. package/src/archivist/recorder/repositories/factory.js +3 -2
  17. package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
  18. package/src/archivist/recorder/repositories/git/index.js +30 -33
  19. package/src/archivist/recorder/repositories/git/index.test.js +98 -2
  20. package/src/archivist/recorder/repositories/mongo/index.js +14 -3
  21. package/src/archivist/recorder/repositories/mongo/index.test.js +27 -1
  22. package/src/archivist/services/index.js +45 -1
  23. package/src/archivist/services/index.test.js +52 -1
  24. package/src/archivist/services/sourceDocument.js +8 -2
  25. package/src/archivist/services/sourceDocument.test.js +37 -0
  26. package/src/archivist/tracking-results/errors.js +5 -0
  27. package/src/archivist/tracking-results/index.js +233 -0
  28. package/src/archivist/tracking-results/index.test.js +406 -0
  29. package/src/archivist/tracking-results/recorder.js +156 -0
  30. package/src/archivist/tracking-results/recorder.test.js +559 -0
  31. package/src/archivist/tracking-results/repository.js +136 -0
  32. package/src/archivist/tracking-results/repository.test.js +763 -0
  33. package/src/archivist/tracking-results/run/dataMapper.js +51 -0
  34. package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
  35. package/src/archivist/tracking-results/run/index.js +115 -0
  36. package/src/archivist/tracking-results/run/index.test.js +221 -0
  37. package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
  38. package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
  39. package/src/archivist/tracking-results/terms-result/index.js +42 -0
  40. package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
  41. package/src/collection-api/routes/index.js +2 -2
  42. package/src/git/errors.js +1 -0
  43. package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +79 -23
  44. package/src/git/index.test.js +266 -0
  45. package/src/git/pathSegment.js +18 -0
  46. package/src/git/pathSegment.test.js +33 -0
  47. package/src/index.js +5 -4
  48. package/src/reporter/index.js +9 -4
  49. package/src/reporter/index.test.js +47 -9
  50. package/src/archivist/recorder/repositories/git/git.test.js +0 -114
  51. /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
  52. /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
@@ -4,15 +4,16 @@ import GitRepository from './git/index.js';
4
4
  import MongoRepository from './mongo/index.js';
5
5
 
6
6
  export default class RepositoryFactory {
7
- static create(params) {
7
+ static create(params, { readOnly = false } = {}) {
8
8
  switch (params.type) {
9
9
  case 'git':
10
10
  return new GitRepository({
11
11
  ...params.git,
12
12
  path: path.resolve(process.cwd(), params.git.path),
13
+ readOnly,
13
14
  });
14
15
  case 'mongo':
15
- return new MongoRepository(params.mongo);
16
+ return new MongoRepository({ ...params.mongo, readOnly });
16
17
  default:
17
18
  throw new Error(`Unknown storage repository configuration for type '${params.type}'`);
18
19
  }
@@ -67,7 +67,8 @@ export function toDomain(commit) {
67
67
  }
68
68
 
69
69
  const [relativeFilePath] = modifiedFilesInCommit;
70
- const snapshotIdsMatch = body.match(/\b[0-9a-f]{5,40}\b/g);
70
+ const bodyWithoutTrailers = Object.keys(trailers).length ? body.split(/\n\n+/).slice(0, -1).join('\n\n') : body; // Trailers, when present, are the last section of the body; their values, such as the hexadecimal segments of a run ID, must not be read as snapshot IDs
71
+ const snapshotIdsMatch = bodyWithoutTrailers.match(/\b[0-9a-f]{5,40}\b/g);
71
72
 
72
73
  const [ termsType, documentId ] = path.basename(relativeFilePath, path.extname(relativeFilePath)).split(TERMS_TYPE_AND_DOCUMENT_ID_SEPARATOR);
73
74
 
@@ -8,42 +8,38 @@ import path from 'path';
8
8
 
9
9
  import mime from 'mime';
10
10
 
11
+ import Git from '../../../../git/index.js';
12
+ import { isPlainPathSegment } from '../../../../git/pathSegment.js';
11
13
  import RepositoryInterface from '../interface.js';
12
14
 
13
15
  import * as DataMapper from './dataMapper.js';
14
- import Git from './git.js';
15
16
 
16
17
  const fs = fsApi.promises;
17
18
 
18
19
  const RECORD_ID_REGEXP = /^[0-9a-f]{7,40}$/i; // Git commit SHA 7 (abbreviated) to 40 (full) hexadecimal characters. Prevent value such as `--output=…` to be parsed as a command-line option
19
20
 
20
- const CONTROL_CHARACTERS_REGEXP = /\p{Cc}/u; // Matches any Unicode "control" character: the C0 range (U+0000 to U+001F), DEL (U+007F) and the C1 range (U+0080 to U+009F), i.e. 65 non-printable characters including NUL. The `u` flag is required for the `\p{...}` property escape to be recognised, otherwise the pattern would match the literal text `p{Cc}`. Legitimate service IDs, terms types and document IDs never contain these, and NUL in particular can truncate a value once it reaches git or the filesystem, so any segment holding one is rejected.
21
-
22
- // Keeps hostile values from reaching git, where a pathspec that resolves outside the repository (such as `../foo/*`) aborts with an error that exposes the repository location.
23
- function isPlainPathSegment(segment) {
24
- return segment.length > 0
25
- && segment !== '.'
26
- && segment !== '..'
27
- && !segment.includes('/')
28
- && !segment.includes('\\')
29
- && !CONTROL_CHARACTERS_REGEXP.test(segment);
30
- }
31
-
32
21
  function canMatchRecordFilePath(...pathSegments) {
33
22
  // A non-string segment means "not provided" (`undefined`, or `false` for an absent document ID) and constrains nothing
34
23
  return pathSegments.every(segment => typeof segment !== 'string' || isPlainPathSegment(segment));
35
24
  }
36
25
 
37
26
  export default class GitRepository extends RepositoryInterface {
38
- constructor({ path, author, publish, snapshotIdentiferTemplate }) {
27
+ constructor({ path, author, publish, snapshotIdentiferTemplate, readOnly = false }) {
39
28
  super();
40
29
  this.path = path;
41
30
  this.needsPublication = publish;
31
+ this.readOnly = readOnly; // Readers share the repository with the tracker, so they must never touch the working tree nor the commit-graph: both race the tracker, and the commit-graph write then fails with `commit-graph.lock: File exists`
42
32
  this.git = new Git({ path: this.path, author });
43
33
  this.snapshotIdentiferTemplate = snapshotIdentiferTemplate;
44
34
  }
45
35
 
46
36
  async initialize() {
37
+ if (this.readOnly) {
38
+ this.git.open();
39
+
40
+ return this;
41
+ }
42
+
47
43
  await this.git.initialize();
48
44
  await this.git.cleanUp(); // Drop all uncommitted changes and remove all leftover files that may be present if the process was killed aggressively
49
45
  await this.git.writeCommitGraph(); // Create or replace the commit graph with a new one to ensure it's fully consistent
@@ -52,6 +48,8 @@ export default class GitRepository extends RepositoryInterface {
52
48
  }
53
49
 
54
50
  async save(record) {
51
+ this.#assertWritable('save records');
52
+
55
53
  const { serviceId, termsType, documentId, fetchDate } = record;
56
54
 
57
55
  if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
@@ -75,6 +73,10 @@ export default class GitRepository extends RepositoryInterface {
75
73
  }
76
74
 
77
75
  async finalize() {
76
+ if (this.readOnly) {
77
+ return;
78
+ }
79
+
78
80
  if (this.needsPublication) {
79
81
  await this.git.pushChanges();
80
82
  }
@@ -214,31 +216,20 @@ export default class GitRepository extends RepositoryInterface {
214
216
  }
215
217
  }
216
218
 
217
- removeAll() {
218
- return this.git.destroyHistory();
219
+ async removeAll() {
220
+ this.#assertWritable('remove records');
221
+
222
+ await this.git.destroyHistory();
219
223
  }
220
224
 
221
225
  async loadRecordContent(record) {
222
226
  const relativeFilePath = DataMapper.generateFilePath(record.serviceId, record.termsType, record.documentId, record.mimeType);
223
227
 
224
- if (record.mimeType != mime.getType('pdf')) {
225
- record.content = await this.git.show(`${record.id}:${relativeFilePath}`);
228
+ const objectPath = `${record.id}:${relativeFilePath}`;
226
229
 
227
- return;
228
- }
229
-
230
- // In case of PDF files, `git show` cannot be used as it converts PDF binary into strings that do not retain the original binary representation
231
- // It is impossible to restore the original binary data from the resulting string
232
- let pdfBuffer;
233
-
234
- try {
235
- await this.git.restore(relativeFilePath, record.id); // Temporarily restore the PDF file to a specific commit
236
- pdfBuffer = await fs.readFile(`${this.path}/${relativeFilePath}`); // …read the content
237
- } finally {
238
- await this.git.restore(relativeFilePath, 'HEAD'); // …and finally restore the file to its most recent state
239
- }
240
-
241
- record.content = pdfBuffer;
230
+ record.content = record.mimeType == mime.getType('pdf')
231
+ ? await this.git.showBuffer(objectPath) // Binary content is read straight from the object database, so nothing is ever written to the working tree shared with the tracker
232
+ : await this.git.show(objectPath);
242
233
  }
243
234
 
244
235
  getDiffStats(recordId) {
@@ -308,6 +299,12 @@ export default class GitRepository extends RepositoryInterface {
308
299
  return this.git.isTracked(`${this.path}/${DataMapper.generateFilePath(serviceId, termsType, documentId)}`);
309
300
  }
310
301
 
302
+ #assertWritable(operation) {
303
+ if (this.readOnly) {
304
+ throw new Error(`Cannot ${operation} in read-only repository ${this.path}`);
305
+ }
306
+ }
307
+
311
308
  async #toDomain(commit, { deferContentLoading } = {}) {
312
309
  if (!commit) {
313
310
  return null;
@@ -2,18 +2,21 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
 
5
- import { expect } from 'chai';
5
+ import { expect, use } from 'chai';
6
+ import chaiAsPromised from 'chai-as-promised';
6
7
  import config from 'config';
7
8
  import mime from 'mime';
8
9
 
10
+ import Git from '../../../../git/index.js';
9
11
  import Snapshot from '../../snapshot.js';
10
12
  import Version from '../../version.js';
11
13
 
12
14
  import { TERMS_TYPE_AND_DOCUMENT_ID_SEPARATOR, SNAPSHOT_ID_MARKER, COMMIT_MESSAGE_PREFIXES } from './dataMapper.js';
13
- import Git from './git.js';
14
15
 
15
16
  import GitRepository from './index.js';
16
17
 
18
+ use(chaiAsPromised);
19
+
17
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
21
 
19
22
  const RECORDER_PATH = path.resolve(__dirname, '../../../../..', config.get('@opentermsarchive/engine.recorder.versions.storage.git.path'));
@@ -395,6 +398,28 @@ describe('GitRepository', () => {
395
398
  expect(record.metadata).to.deep.equal(METADATA);
396
399
  });
397
400
 
401
+ context('when a metadata value contains hexadecimal segments', () => {
402
+ let recordWithRunId;
403
+
404
+ before(async () => {
405
+ const { id: recordId } = await subject.save(new Version({
406
+ serviceId: SERVICE_PROVIDER_ID,
407
+ termsType: TERMS_TYPE,
408
+ content: `${CONTENT} (updated)`,
409
+ fetchDate: FETCH_DATE_LATER,
410
+ snapshotIds: [SNAPSHOT_ID],
411
+ mimeType: HTML_MIME_TYPE,
412
+ metadata: { ...METADATA, 'x-run-id': 'ota-run-189e7be3-60ef-40c7-ac28-81a44288e105' },
413
+ }));
414
+
415
+ recordWithRunId = await subject.findById(recordId);
416
+ });
417
+
418
+ it('returns only the snapshot ID', () => {
419
+ expect(recordWithRunId.snapshotIds).to.deep.equal([SNAPSHOT_ID]);
420
+ });
421
+ });
422
+
398
423
  context('when requested record does not exist', () => {
399
424
  it('returns null', async () => {
400
425
  expect(await subject.findById('inexistantID')).to.equal(null);
@@ -2063,4 +2088,75 @@ describe('GitRepository', () => {
2063
2088
  });
2064
2089
  });
2065
2090
  });
2091
+
2092
+ context('when read-only', () => {
2093
+ const UNTRACKED_FILE_PATH = `${RECORDER_PATH}/untracked-file.md`;
2094
+ const COMMIT_GRAPH_PATH = `${RECORDER_PATH}/.git/objects/info/commit-graph`;
2095
+
2096
+ let writer;
2097
+ let record;
2098
+
2099
+ before(async () => {
2100
+ writer = new GitRepository({
2101
+ ...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
2102
+ path: RECORDER_PATH,
2103
+ });
2104
+
2105
+ await writer.git.initialize(); // Bypass GitRepository#initialize, which writes the commit-graph, to check that the read-only repository does not write it either
2106
+ fs.rmSync(COMMIT_GRAPH_PATH, { force: true });
2107
+
2108
+ record = await writer.save(new Version({
2109
+ serviceId: SERVICE_PROVIDER_ID,
2110
+ termsType: TERMS_TYPE,
2111
+ content: CONTENT,
2112
+ fetchDate: FETCH_DATE,
2113
+ snapshotIds: [SNAPSHOT_ID],
2114
+ }));
2115
+
2116
+ fs.writeFileSync(UNTRACKED_FILE_PATH, CONTENT);
2117
+
2118
+ subject = new GitRepository({
2119
+ ...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
2120
+ path: RECORDER_PATH,
2121
+ readOnly: true,
2122
+ });
2123
+
2124
+ await subject.initialize();
2125
+ await subject.finalize();
2126
+ });
2127
+
2128
+ after(() => writer.removeAll());
2129
+
2130
+ it('reads the records', async () => {
2131
+ const records = await subject.findAll();
2132
+
2133
+ expect(records.map(({ id }) => id)).to.deep.equal([record.id]);
2134
+ });
2135
+
2136
+ it('leaves uncommitted changes untouched', () => {
2137
+ expect(fs.existsSync(UNTRACKED_FILE_PATH)).to.be.true;
2138
+ });
2139
+
2140
+ it('does not write the commit-graph', () => {
2141
+ expect(fs.existsSync(COMMIT_GRAPH_PATH)).to.be.false;
2142
+ });
2143
+
2144
+ it('rejects saving records', async () => {
2145
+ await expect(subject.save(record)).to.be.rejectedWith(Error, /read-only/);
2146
+ });
2147
+
2148
+ it('rejects removing records', async () => {
2149
+ await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
2150
+ });
2151
+
2152
+ it('rejects opening a missing repository', async () => {
2153
+ const missingRepository = new GitRepository({
2154
+ ...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
2155
+ path: `${RECORDER_PATH}-missing`,
2156
+ readOnly: true,
2157
+ });
2158
+
2159
+ await expect(missingRepository.initialize()).to.be.rejectedWith(Error, /does not exist/);
2160
+ });
2161
+ });
2066
2162
  });
@@ -10,12 +10,13 @@ import RepositoryInterface from '../interface.js';
10
10
  import * as DataMapper from './dataMapper.js';
11
11
 
12
12
  export default class MongoRepository extends RepositoryInterface {
13
- constructor({ database: databaseName, collection: collectionName, connectionURI }) {
13
+ constructor({ database: databaseName, collection: collectionName, connectionURI, readOnly = false }) {
14
14
  super();
15
15
 
16
16
  this.client = new MongoClient(connectionURI);
17
17
  this.databaseName = databaseName;
18
18
  this.collectionName = collectionName;
19
+ this.readOnly = readOnly;
19
20
  }
20
21
 
21
22
  async initialize() {
@@ -34,6 +35,8 @@ export default class MongoRepository extends RepositoryInterface {
34
35
  }
35
36
 
36
37
  async save(record) {
38
+ this.#assertWritable('save records');
39
+
37
40
  const { serviceId, termsType, documentId } = record;
38
41
 
39
42
  if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
@@ -223,8 +226,16 @@ export default class MongoRepository extends RepositoryInterface {
223
226
  }
224
227
  }
225
228
 
226
- removeAll() {
227
- return this.collection.deleteMany();
229
+ async removeAll() {
230
+ this.#assertWritable('remove records');
231
+
232
+ await this.collection.deleteMany();
233
+ }
234
+
235
+ #assertWritable(operation) {
236
+ if (this.readOnly) {
237
+ throw new Error(`Cannot ${operation} in read-only repository ${this.databaseName}.${this.collectionName}`);
238
+ }
228
239
  }
229
240
 
230
241
  async loadRecordContent(record) {
@@ -2,7 +2,8 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
 
5
- import { expect } from 'chai';
5
+ import { expect, use } from 'chai';
6
+ import chaiAsPromised from 'chai-as-promised';
6
7
  import config from 'config';
7
8
  import mime from 'mime';
8
9
  import { MongoClient, ObjectId } from 'mongodb';
@@ -12,6 +13,8 @@ import Version from '../../version.js';
12
13
 
13
14
  import MongoRepository from './index.js';
14
15
 
16
+ use(chaiAsPromised);
17
+
15
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
19
 
17
20
  const { connectionURI } = config.get('@opentermsarchive/engine.recorder.snapshots.storage.mongo');
@@ -1944,4 +1947,27 @@ describe('MongoRepository', () => {
1944
1947
  });
1945
1948
  });
1946
1949
  });
1950
+
1951
+ context('when read-only', () => {
1952
+ before(() => {
1953
+ subject = new MongoRepository({
1954
+ ...config.get('@opentermsarchive/engine.recorder.versions.storage.mongo'),
1955
+ readOnly: true,
1956
+ });
1957
+ });
1958
+
1959
+ it('rejects saving records', async () => {
1960
+ await expect(subject.save(new Version({
1961
+ serviceId: SERVICE_PROVIDER_ID,
1962
+ termsType: TERMS_TYPE,
1963
+ content: CONTENT,
1964
+ fetchDate: FETCH_DATE,
1965
+ snapshotIds: [SNAPSHOT_ID],
1966
+ }))).to.be.rejectedWith(Error, /read-only/);
1967
+ });
1968
+
1969
+ it('rejects removing records', async () => {
1970
+ await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
1971
+ });
1972
+ });
1947
1973
  });
@@ -2,8 +2,10 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { pathToFileURL } from 'url';
4
4
 
5
+ import async from 'async';
5
6
  import config from 'config';
6
7
 
8
+ import Git from '../../git/index.js';
7
9
  import * as exposedFilters from '../extract/exposedFilters.js';
8
10
 
9
11
  import Service from './service.js';
@@ -13,6 +15,8 @@ import Terms from './terms.js';
13
15
  export const DECLARATIONS_PATH = './declarations';
14
16
  const declarationsPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.collectionPath'), DECLARATIONS_PATH);
15
17
 
18
+ const MAX_PARALLEL_DECLARATIONS_READS = 5; // Reading declarations at a commit spawns one git process per service; left unbounded, a large collection would exhaust the file descriptors or processes allowed to the engine
19
+
16
20
  const JSON_EXT = '.json';
17
21
  const JS_EXT = '.js';
18
22
  const HISTORY_SUFFIX = '.history';
@@ -106,10 +110,11 @@ function createWrappedFilter(baseFunction, filterName, filterParams) {
106
110
  return;
107
111
  }
108
112
 
109
- if (filterParams || exposedFilters[filterName]) { // Built-in filters always receive their parameters before the context, even when none are declared
113
+ if (filterParams !== undefined || exposedFilters[filterName]) { // Built-in filters always receive their parameters before the context, even when none are declared
110
114
  const wrappedFilter = (webPageDOM, context) => baseFunction(webPageDOM, filterParams, context);
111
115
 
112
116
  Object.defineProperty(wrappedFilter, 'name', { value: filterName });
117
+ Object.defineProperty(wrappedFilter, 'declaration', { value: filterParams === undefined ? filterName : { [filterName]: filterParams } }); // Keep the declared form, as the parameters are otherwise only reachable through the closure
113
118
 
114
119
  return wrappedFilter;
115
120
  }
@@ -145,6 +150,45 @@ export function getServiceFilters(serviceFilters, declaredFilters) {
145
150
  export async function getDeclaredServicesIds() {
146
151
  const fileNames = await fs.readdir(declarationsPath);
147
152
 
153
+ return declaredServicesIdsFromFileNames(fileNames);
154
+ }
155
+
156
+ export function getDeclarationsCommit() { // Resolves to null when the declarations are not versioned with Git
157
+ return Git.getHeadSha(declarationsPath);
158
+ }
159
+
160
+ // Returns the [{ serviceId, termsType }] declared at the given commit of the declarations repository.
161
+ // Reads through git so the answer reflects the declarations exactly as they were at that commit, not as they are on disk; used by crash recovery to derive the coverage of a run that referenced this commit.
162
+ export async function getDeclaredTermsAtCommit(commit) {
163
+ const fileNames = await Git.listFilesAtCommit(declarationsPath, commit);
164
+ const serviceIds = declaredServicesIdsFromFileNames(fileNames);
165
+
166
+ return declaredTermsOf(serviceIds, async serviceId => {
167
+ const rawDeclaration = await Git.readFileAtCommit(declarationsPath, commit, `${serviceId}${JSON_EXT}`);
168
+
169
+ try {
170
+ return JSON.parse(rawDeclaration);
171
+ } catch (error) {
172
+ throw new Error(`The "${serviceId}" service declaration at commit ${commit} is malformed and cannot be parsed`);
173
+ }
174
+ });
175
+ }
176
+
177
+ export async function getDeclaredTerms() { // Working-tree counterpart of getDeclaredTermsAtCommit; used as approximation when a declarations commit is not reachable anymore
178
+ return declaredTermsOf(await getDeclaredServicesIds(), loadServiceDeclaration);
179
+ }
180
+
181
+ async function declaredTermsOf(serviceIds, loadDeclaration) {
182
+ const declaredTermsPerService = await async.mapLimit(serviceIds, MAX_PARALLEL_DECLARATIONS_READS, async serviceId => {
183
+ const declaration = await loadDeclaration(serviceId);
184
+
185
+ return Object.keys(declaration.terms ?? {}).map(termsType => ({ serviceId, termsType }));
186
+ });
187
+
188
+ return declaredTermsPerService.flat(); // Flattened in serviceIds order to keep the result deterministic regardless of read completion order
189
+ }
190
+
191
+ function declaredServicesIdsFromFileNames(fileNames) {
148
192
  return fileNames
149
193
  .filter(fileName => fileName.endsWith(JSON_EXT) && !fileName.includes(`${HISTORY_SUFFIX}${JSON_EXT}`))
150
194
  .map(fileName => path.basename(fileName, JSON_EXT));
@@ -2,10 +2,12 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
 
4
4
  import { expect, use } from 'chai';
5
+ import config from 'config';
5
6
  import sinon from 'sinon';
6
7
  import sinonChai from 'sinon-chai';
7
8
 
8
9
  import expectedServices from '../../../test/fixtures/services.js';
10
+ import Git, { GitObjectNotFoundError } from '../../git/index.js';
9
11
  import createWebPageDOM from '../extract/dom.js';
10
12
  import * as exposedFilters from '../extract/exposedFilters.js';
11
13
 
@@ -13,7 +15,7 @@ import Service from './service.js';
13
15
  import SourceDocument from './sourceDocument.js';
14
16
  import Terms from './terms.js';
15
17
 
16
- import { getDeclaredServicesIds, loadServiceDeclaration, loadServiceFilters, getServiceFilters, createSourceDocuments, createServiceFromDeclaration, load, loadWithHistory } from './index.js';
18
+ import { getDeclaredServicesIds, getDeclaredTermsAtCommit, loadServiceDeclaration, loadServiceFilters, getServiceFilters, createSourceDocuments, createServiceFromDeclaration, load, loadWithHistory } from './index.js';
17
19
 
18
20
  use(sinonChai);
19
21
 
@@ -151,6 +153,36 @@ describe('Services', () => {
151
153
  });
152
154
  });
153
155
 
156
+ describe('#getDeclaredTermsAtCommit', () => {
157
+ const declarationsPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.collectionPath'), './declarations');
158
+
159
+ afterEach(() => sinon.restore());
160
+
161
+ it('returns the terms declared at the given commit, whatever the working tree contains', async function () {
162
+ this.timeout(10000);
163
+
164
+ const commit = await Git.getHeadSha(declarationsPath); // The test declarations are committed in the engine repository itself, so its HEAD reflects them exactly
165
+ const expected = Object.entries(expectedServices).flatMap(([ serviceId, service ]) => service.getTermsTypes().map(termsType => ({ serviceId, termsType })));
166
+
167
+ sinon.stub(fs, 'readdir').resolves(['uncommitted-service.json']); // Make the working tree differ from the commit: it must not be read
168
+ sinon.stub(fs, 'readFile').resolves(JSON.stringify({ name: 'Uncommitted service', terms: { Imprint: {} } }));
169
+
170
+ expect(await getDeclaredTermsAtCommit(commit)).to.have.deep.members(expected);
171
+ });
172
+
173
+ it('throws a GitObjectNotFoundError for an unknown commit', async () => {
174
+ try {
175
+ await getDeclaredTermsAtCommit('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef');
176
+ } catch (error) {
177
+ expect(error).to.be.an.instanceOf(GitObjectNotFoundError);
178
+
179
+ return;
180
+ }
181
+
182
+ expect.fail('No error was thrown');
183
+ });
184
+ });
185
+
154
186
  describe('#loadServiceDeclaration', () => {
155
187
  let readFile;
156
188
 
@@ -310,6 +342,19 @@ describe('Services', () => {
310
342
  expect(result[0](null, 'context')).to.equal('foo');
311
343
  });
312
344
 
345
+ it('keeps the declared form of filters declared with parameters', () => {
346
+ const [filter] = getServiceFilters({ paramFilter: (dom, param) => param }, [{ paramFilter: [ 'foo', 'bar' ] }]);
347
+
348
+ expect(filter.declaration).to.deep.equal({ paramFilter: [ 'foo', 'bar' ] });
349
+ });
350
+
351
+ it('keeps the name as the declared form of exposed filters declared by string name', () => {
352
+ const [filterName] = Object.keys(exposedFilters);
353
+ const [filter] = getServiceFilters({}, [filterName]);
354
+
355
+ expect(filter.declaration).to.equal(filterName);
356
+ });
357
+
313
358
  describe('parameters passed to filters', () => {
314
359
  let serviceLoadedFilters;
315
360
  let passedDOM;
@@ -348,6 +393,12 @@ describe('Services', () => {
348
393
  testParameterPassing({ param1: 'param1', param2: 'param2' });
349
394
  });
350
395
  });
396
+
397
+ context('as a falsy value', () => {
398
+ it('passes parameters correctly', () => {
399
+ testParameterPassing(false);
400
+ });
401
+ });
351
402
  });
352
403
  });
353
404
 
@@ -40,8 +40,14 @@ export default class SourceDocument {
40
40
  }
41
41
 
42
42
  clearContent() {
43
- this.content = null;
43
+ this.content = null; // Only the potentially large content is cleared: the MIME type is read after the extraction, to record the tracking results
44
+ }
45
+
46
+ resetObservations() {
47
+ // mimeType and snapshotId are observations of a single tracking attempt, but they are stored on declaration objects that live for the whole process: without this reset, a failed fetch would expose the previous run's values as if they belonged to the failed attempt.
48
+ // The proper pattern would be for the fetch and extract pipeline to return its observations instead of mutating the declarations, letting consumers build their records from run-scoped data; this reset contains that debt rather than fixing it.
44
49
  this.mimeType = null;
50
+ this.snapshotId = null;
45
51
  }
46
52
 
47
53
  static extractCssSelectorsFromProperty(property) {
@@ -83,7 +89,7 @@ export default class SourceDocument {
83
89
  fetch: this.location,
84
90
  select: this.contentSelectors,
85
91
  remove: this.insignificantContentSelectors,
86
- filter: this.filters ? this.filters.map(filter => filter.name) : undefined,
92
+ filter: this.filters ? this.filters.map(filter => filter.declaration ?? filter.name) : undefined, // Filters declared with parameters carry their declared form, so that a change of parameters is persisted
87
93
  executeClientScripts: this.executeClientScripts,
88
94
  };
89
95
  }
@@ -214,6 +214,29 @@ describe('SourceDocument', () => {
214
214
  });
215
215
  });
216
216
 
217
+ describe('#clearContent', () => {
218
+ it('clears the content but keeps the MIME type', () => {
219
+ const sourceDocument = new SourceDocument({ location: URL, content: '<html></html>', mimeType: 'text/html' });
220
+
221
+ sourceDocument.clearContent();
222
+
223
+ expect(sourceDocument.content).to.be.null;
224
+ expect(sourceDocument.mimeType).to.equal('text/html');
225
+ });
226
+ });
227
+
228
+ describe('#resetObservations', () => {
229
+ it('clears the MIME type and the snapshot ID observed by a previous tracking', () => {
230
+ const sourceDocument = new SourceDocument({ location: URL, mimeType: 'text/html' });
231
+
232
+ sourceDocument.snapshotId = 'abc123';
233
+ sourceDocument.resetObservations();
234
+
235
+ expect(sourceDocument.mimeType).to.be.null;
236
+ expect(sourceDocument.snapshotId).to.be.null;
237
+ });
238
+ });
239
+
217
240
  describe('#toPersistence', () => {
218
241
  it('converts basic source document declarations into JSON representation', () => {
219
242
  const result = new SourceDocument({
@@ -275,5 +298,19 @@ describe('SourceDocument', () => {
275
298
 
276
299
  expect(result).to.deep.equal(expectedResult);
277
300
  });
301
+
302
+ it('converts filters declared with parameters to their declared form', () => {
303
+ const filterWithParameters = () => {};
304
+
305
+ Object.defineProperty(filterWithParameters, 'declaration', { value: { removeQueryParams: ['utm_source'] } });
306
+
307
+ const result = new SourceDocument({
308
+ location: URL,
309
+ contentSelectors: 'body',
310
+ filters: [ filterWithParameters, function filterSomething() {} ],
311
+ }).toPersistence();
312
+
313
+ expect(result.filter).to.deep.equal([{ removeQueryParams: ['utm_source'] }, 'filterSomething' ]);
314
+ });
278
315
  });
279
316
  });
@@ -0,0 +1,5 @@
1
+ /* eslint-disable max-classes-per-file */ // Grouping the module's error types here is the point of an errors module
2
+
3
+ export class MissingCollectionIdError extends Error {} // The collection metadata does not provide the id that identifies the collection in every persisted run; tracking-results cannot record without it
4
+
5
+ export class UnreadableRunError extends Error {} // The persisted run.json cannot be read as a valid Run (corrupted JSON, incompatible schema from another engine version): recovery is impossible by construction, unlike infrastructure failures for which a retry is meaningful