@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,18 @@
|
|
|
1
|
+
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.
|
|
2
|
+
|
|
3
|
+
const WINDOWS_RESERVED_CHARACTERS_REGEXP = /[:"<>|*?]/; // Forbidden in Windows file names, so a segment holding one cannot exist on every supported platform; the service ID guidelines exclude them for the same reason
|
|
4
|
+
|
|
5
|
+
// 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.
|
|
6
|
+
export function isPlainPathSegment(segment) {
|
|
7
|
+
return segment.length > 0
|
|
8
|
+
&& segment !== '.'
|
|
9
|
+
&& segment !== '..'
|
|
10
|
+
&& !segment.includes('/')
|
|
11
|
+
&& !segment.includes('\\')
|
|
12
|
+
&& !CONTROL_CHARACTERS_REGEXP.test(segment);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Accepts only segments that are usable as a file name on every supported platform. As git C-quotes only double-quotes, backslashes and control characters in the paths it prints (see core.quotePath), all rejected here, an accepted segment also never comes back quoted from a git log.
|
|
16
|
+
export function isPortableFileName(segment) {
|
|
17
|
+
return isPlainPathSegment(segment) && !WINDOWS_RESERVED_CHARACTERS_REGEXP.test(segment);
|
|
18
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
|
|
3
|
+
import { isPlainPathSegment, isPortableFileName } from './pathSegment.js';
|
|
4
|
+
|
|
5
|
+
describe('PathSegment', () => {
|
|
6
|
+
describe('#isPlainPathSegment', () => {
|
|
7
|
+
[ 'Facebook', 'Terms of Service', 'Facebook v2.0', 'service·A' ].forEach(segment => {
|
|
8
|
+
it(`accepts ${JSON.stringify(segment)}`, () => {
|
|
9
|
+
expect(isPlainPathSegment(segment)).to.be.true;
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
[ '', '.', '..', 'a/b', 'a\\b', 'with\0null', 'line\nbreak' ].forEach(segment => {
|
|
14
|
+
it(`rejects ${JSON.stringify(segment)}`, () => {
|
|
15
|
+
expect(isPlainPathSegment(segment)).to.be.false;
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('#isPortableFileName', () => {
|
|
21
|
+
[ 'Facebook', 'Terms of Service', 'Booking.com', 'Yahoo!', 'service·A' ].forEach(segment => {
|
|
22
|
+
it(`accepts ${JSON.stringify(segment)}`, () => {
|
|
23
|
+
expect(isPortableFileName(segment)).to.be.true;
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
[ 'Service "A"', 're:start', 'a<b', 'a>b', 'a|b', 'a*b', 'a?b', '', '.', '..', 'a/b', 'a\\b', 'with\0null' ].forEach(segment => {
|
|
28
|
+
it(`rejects ${JSON.stringify(segment)}`, () => {
|
|
29
|
+
expect(isPortableFileName(segment)).to.be.false;
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/index.js
CHANGED
|
@@ -12,10 +12,11 @@ import Reporter from './reporter/index.js';
|
|
|
12
12
|
const require = createRequire(import.meta.url);
|
|
13
13
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
14
14
|
|
|
15
|
-
async function initialize(services) {
|
|
15
|
+
async function initialize(services, { trackingResultsConfig } = {}) {
|
|
16
16
|
const archivist = new Archivist({
|
|
17
17
|
recorderConfig: config.get('@opentermsarchive/engine.recorder'),
|
|
18
18
|
fetcherConfig: config.get('@opentermsarchive/engine.fetcher'),
|
|
19
|
+
trackingResultsConfig,
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
archivist.attach(logger);
|
|
@@ -28,7 +29,7 @@ async function initialize(services) {
|
|
|
28
29
|
logger.info(`Start engine v${PACKAGE_VERSION}${collectionName}\n`);
|
|
29
30
|
|
|
30
31
|
if (services?.length) {
|
|
31
|
-
services = services.filter(serviceId => {
|
|
32
|
+
services = [...new Set(services)].filter(serviceId => { // Deduplicated so that a service ID given twice is not tracked twice in the same run
|
|
32
33
|
const isServiceDeclared = archivist.services[serviceId];
|
|
33
34
|
|
|
34
35
|
if (!isServiceDeclared) {
|
|
@@ -43,7 +44,7 @@ async function initialize(services) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export default async function track({ services, types, schedule }) {
|
|
46
|
-
const { archivist, services: filteredServices } = await initialize(services);
|
|
47
|
+
const { archivist, services: filteredServices } = await initialize(services, { trackingResultsConfig: config.get('@opentermsarchive/engine.tracking-results') });
|
|
47
48
|
|
|
48
49
|
// Technical upgrade pass: apply changes from engine, dependency, or declaration upgrades.
|
|
49
50
|
// This regenerates versions from existing snapshots with updated extraction logic.
|
|
@@ -84,7 +85,7 @@ export default async function track({ services, types, schedule }) {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
export async function applyTechnicalUpgrades({ services, types }) {
|
|
87
|
-
const { archivist, services: filteredServices } = await initialize(services);
|
|
88
|
+
const { archivist, services: filteredServices } = await initialize(services); // Without tracking-results: technical upgrades never record any, and initializing the module would finalize as crashed the run that a tracking process may have in progress
|
|
88
89
|
|
|
89
90
|
await archivist.applyTechnicalUpgrades({ services: filteredServices, types });
|
|
90
91
|
}
|
package/src/reporter/index.js
CHANGED
|
@@ -144,9 +144,14 @@ No changes were found in the last run, so no new version has been recorded.`,
|
|
|
144
144
|
const latestDeclarationLink = `[Latest declaration](${declarationFileUrl})`;
|
|
145
145
|
const latestVersionLink = `[Latest version](${this.reporter.generateVersionURL(terms.service.name, terms.type)})`;
|
|
146
146
|
const snapshotsBaseUrl = this.reporter.generateSnapshotsBaseUrl(terms.service.name, terms.type);
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
const recordedSourceDocuments = terms.sourceDocuments.filter(sourceDocument => sourceDocument.snapshotId); // A source document that has never been recorded has no snapshot file to link to, and its unknown MIME type would produce a link to a nonexistent ".null" file
|
|
148
|
+
let latestSnapshotsLink = '';
|
|
149
|
+
|
|
150
|
+
if (recordedSourceDocuments.length) {
|
|
151
|
+
latestSnapshotsLink = terms.hasMultipleSourceDocuments
|
|
152
|
+
? `Latest snapshots:\n - ${recordedSourceDocuments.map(sourceDocument => `[${sourceDocument.id}](${snapshotsBaseUrl}.%20#${sourceDocument.id}.${mime.getExtension(sourceDocument.mimeType)})`).join('\n - ')}`
|
|
153
|
+
: `[Latest snapshot](${snapshotsBaseUrl}.${mime.getExtension(recordedSourceDocuments[0].mimeType)})`;
|
|
154
|
+
}
|
|
150
155
|
|
|
151
156
|
/* eslint-disable no-irregular-whitespace */
|
|
152
157
|
return `
|
|
@@ -185,7 +190,7 @@ If the source documents are accessible in a browser but fetching them always fai
|
|
|
185
190
|
|
|
186
191
|
- ${latestDeclarationLink}
|
|
187
192
|
${this.repositories.versions ? `- ${latestVersionLink}` : ''}
|
|
188
|
-
${this.repositories.snapshots ? `- ${latestSnapshotsLink}` : ''}
|
|
193
|
+
${this.repositories.snapshots && latestSnapshotsLink ? `- ${latestSnapshotsLink}` : ''}
|
|
189
194
|
`;
|
|
190
195
|
/* eslint-enable no-irregular-whitespace */
|
|
191
196
|
}
|
|
@@ -64,17 +64,21 @@ describe('Reporter', () => {
|
|
|
64
64
|
describe('#generateDescription', () => {
|
|
65
65
|
const buildReporter = () => new Reporter({
|
|
66
66
|
type: 'github',
|
|
67
|
-
repositories: { declarations: 'OpenTermsArchive/test-declarations' },
|
|
67
|
+
repositories: { declarations: 'OpenTermsArchive/test-declarations', snapshots: 'OpenTermsArchive/test-snapshots' },
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
const buildTerms = ({ sourceCount = 1 } = {}) => {
|
|
71
|
-
const sourceDocuments = Array.from({ length: sourceCount }, (_, index) =>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
const buildTerms = ({ sourceCount = 1, hasSnapshots = true, withoutSnapshotIndexes = [] } = {}) => {
|
|
71
|
+
const sourceDocuments = Array.from({ length: sourceCount }, (_, index) => {
|
|
72
|
+
const hasSnapshot = hasSnapshots && !withoutSnapshotIndexes.includes(index); // A document that has never been recorded has neither a snapshot ID nor an observed MIME type
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
id: `source-${index}`,
|
|
76
|
+
location: `https://example.com/source-${index}`,
|
|
77
|
+
mimeType: hasSnapshot ? 'text/html' : null,
|
|
78
|
+
snapshotId: hasSnapshot ? `snapshot-${index}` : null,
|
|
79
|
+
toPersistence: () => ({ fetch: `https://example.com/source-${index}` }),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
78
82
|
|
|
79
83
|
return {
|
|
80
84
|
service: { id: 'TestService', name: 'TestService' },
|
|
@@ -94,6 +98,40 @@ describe('Reporter', () => {
|
|
|
94
98
|
|
|
95
99
|
const error = { reasons: ['HTTP code 404'] };
|
|
96
100
|
|
|
101
|
+
context('when the source documents have been recorded as snapshots', () => {
|
|
102
|
+
it('mentions that the missed versions might be recovered', () => {
|
|
103
|
+
const description = buildReporter().generateDescription({ error, terms: buildTerms() });
|
|
104
|
+
|
|
105
|
+
expect(description).to.include('it might still be possible to recover the missed versions');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
context('when the source documents could not be recorded as snapshots', () => {
|
|
110
|
+
it('does not suggest that the missed versions might be recovered', () => {
|
|
111
|
+
const description = buildReporter().generateDescription({ error, terms: buildTerms({ hasSnapshots: false }) });
|
|
112
|
+
|
|
113
|
+
expect(description).to.include('has not been recorded as a snapshot');
|
|
114
|
+
expect(description).to.not.include('it might still be possible to recover the missed versions');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('omits the snapshot link, which would point to a nonexistent file', () => {
|
|
118
|
+
const description = buildReporter().generateDescription({ error, terms: buildTerms({ hasSnapshots: false }) });
|
|
119
|
+
|
|
120
|
+
expect(description).to.not.include('Latest snapshot');
|
|
121
|
+
expect(description).to.not.include('.null');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
context('when a source document of combined terms has never been recorded as a snapshot', () => {
|
|
126
|
+
it('omits its snapshot link and keeps the ones of the recorded documents', () => {
|
|
127
|
+
const description = buildReporter().generateDescription({ error, terms: buildTerms({ sourceCount: 2, withoutSnapshotIndexes: [1] }) });
|
|
128
|
+
|
|
129
|
+
expect(description).to.include('#source-0.html'); // The `#<id>` fragment only appears in snapshot links, unlike the document locations listed in the accessibility checklist
|
|
130
|
+
expect(description).to.not.include('#source-1');
|
|
131
|
+
expect(description).to.not.include('.null');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
97
135
|
context('when the terms has a single source document', () => {
|
|
98
136
|
it('deep-links to the contribution tool with the serialized declaration as the edit target', () => {
|
|
99
137
|
const description = buildReporter().generateDescription({ error, terms: buildTerms({ sourceCount: 1 }) });
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
4
|
-
|
|
5
|
-
import { expect } from 'chai';
|
|
6
|
-
import config from 'config';
|
|
7
|
-
|
|
8
|
-
import Git from './git.js';
|
|
9
|
-
|
|
10
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
const RECORDER_PATH = path.resolve(__dirname, '../../../../../', config.get('@opentermsarchive/engine.recorder.versions.storage.git.path'));
|
|
12
|
-
|
|
13
|
-
describe('Git', () => {
|
|
14
|
-
const DEFAULT_CONTENT = 'default content';
|
|
15
|
-
const DEFAULT_COMMIT_MESSAGE = 'default commit message';
|
|
16
|
-
let subject;
|
|
17
|
-
|
|
18
|
-
before(() => {
|
|
19
|
-
subject = new Git({
|
|
20
|
-
path: RECORDER_PATH,
|
|
21
|
-
author: {
|
|
22
|
-
name: config.get('@opentermsarchive/engine.recorder.versions.storage.git.author.name'),
|
|
23
|
-
email: config.get('@opentermsarchive/engine.recorder.versions.storage.git.author.email'),
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
return subject.initialize();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('#commit', () => {
|
|
31
|
-
const expectedFilePath = `${RECORDER_PATH}/test.md`;
|
|
32
|
-
|
|
33
|
-
let commitId;
|
|
34
|
-
|
|
35
|
-
before(async () => {
|
|
36
|
-
await fs.writeFile(expectedFilePath, DEFAULT_CONTENT);
|
|
37
|
-
|
|
38
|
-
await subject.add(expectedFilePath);
|
|
39
|
-
commitId = await subject.commit({ filePath: expectedFilePath, message: DEFAULT_COMMIT_MESSAGE });
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
after(() => subject.destroyHistory());
|
|
43
|
-
|
|
44
|
-
it('returns a full length SHA1 commit ID', () => {
|
|
45
|
-
expect(commitId).to.match(/\b[0-9a-f]{40}\b/);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
context('when stage area is dirty', () => {
|
|
49
|
-
const expectedFileName = 'file-to-commit.md';
|
|
50
|
-
const expectedFilePath = `${RECORDER_PATH}/${expectedFileName}`;
|
|
51
|
-
const unwantedFilePath = `${RECORDER_PATH}/unwanted-file.md`;
|
|
52
|
-
let commit;
|
|
53
|
-
let committedFiles;
|
|
54
|
-
let committedFileName;
|
|
55
|
-
|
|
56
|
-
before(async () => {
|
|
57
|
-
await fs.writeFile(expectedFilePath, DEFAULT_CONTENT);
|
|
58
|
-
await fs.writeFile(unwantedFilePath, DEFAULT_CONTENT);
|
|
59
|
-
|
|
60
|
-
await subject.add(expectedFilePath);
|
|
61
|
-
await subject.add(unwantedFilePath);
|
|
62
|
-
|
|
63
|
-
const commitId = await subject.commit({ filePath: expectedFilePath, message: DEFAULT_COMMIT_MESSAGE });
|
|
64
|
-
|
|
65
|
-
commit = await subject.getCommit([commitId]);
|
|
66
|
-
|
|
67
|
-
if (!commit) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
({ files: committedFiles } = commit.diff);
|
|
72
|
-
([{ file: committedFileName }] = committedFiles);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
after(() => subject.destroyHistory());
|
|
76
|
-
|
|
77
|
-
it('commits the specified file', () => {
|
|
78
|
-
expect(committedFileName).to.equal(expectedFileName);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('commits only one file', () => {
|
|
82
|
-
expect(committedFiles).to.have.lengthOf(1);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe('#cleanUp', () => {
|
|
88
|
-
context('when a commit-graph lock has been left behind by an interrupted process', () => {
|
|
89
|
-
const infoDirectoryPath = path.join(RECORDER_PATH, '.git', 'objects', 'info');
|
|
90
|
-
const lockFilePath = path.join(infoDirectoryPath, 'commit-graph.lock');
|
|
91
|
-
|
|
92
|
-
before(async () => {
|
|
93
|
-
const filePath = `${RECORDER_PATH}/file-to-clean.md`;
|
|
94
|
-
|
|
95
|
-
await fs.writeFile(filePath, DEFAULT_CONTENT);
|
|
96
|
-
await subject.add(filePath);
|
|
97
|
-
await subject.commit({ filePath, message: DEFAULT_COMMIT_MESSAGE });
|
|
98
|
-
|
|
99
|
-
await fs.mkdir(infoDirectoryPath, { recursive: true });
|
|
100
|
-
await fs.writeFile(lockFilePath, '');
|
|
101
|
-
|
|
102
|
-
await subject.cleanUp();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
after(() => subject.destroyHistory());
|
|
106
|
-
|
|
107
|
-
it('removes the stale commit-graph lock', async () => {
|
|
108
|
-
const infoDirectoryContent = await fs.readdir(infoDirectoryPath);
|
|
109
|
-
|
|
110
|
-
expect(infoDirectoryContent).to.not.include('commit-graph.lock');
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
});
|
|
File without changes
|
|
File without changes
|