Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/run-parallel-tests.ts
3544 views
1
// horrendous hack to get us out of the CI deadlock
2
let parse, expandGlobSync, basename: any, relative: any;
3
try {
4
const path = await import("stdlib/path");
5
basename = path.basename;
6
relative = path.relative;
7
expandGlobSync = (await import("stdlib/fs")).expandGlobSync;
8
// let { expandGlobSync } = await import("stdlib/fs");
9
// let { basename, relative } = await import("stdlib/path");
10
// let { parse } = await import("stdlib/flags");
11
parse = (await import("stdlib/flags")).parse;
12
} catch (_e) {
13
const path = await import("https://deno.land/std/path/mod.ts");
14
basename = path.basename;
15
relative = path.relative;
16
expandGlobSync = (await import("https://deno.land/std/fs/mod.ts")).expandGlobSync;
17
parse = (await import("https://deno.land/std/flags/mod.ts")).parse;
18
}
19
20
// Command line flags to use when calling `run-paralell-tests.sh`.
21
const flags = parse(Deno.args, {
22
boolean: ["json-for-ci", "verbose", "dry-run"],
23
string: ["n", "timing-file"],
24
default: {
25
verbose: false,
26
"dry-run": false,
27
"json-for-ci": false,
28
"timing-file": "timing.txt",
29
},
30
});
31
32
// Name of the file containing test timing for buckets grouping
33
const timingFile = flags["timing-file"];
34
// Use detailed smoke-all timing results when generating json for CI matrix runs
35
const detailedSmokeAll = flags["json-for-ci"];
36
37
const smokeAllTestFile = "./smoke/smoke-all.test.ts";
38
39
let timingFileContent;
40
41
try {
42
timingFileContent = Deno.readTextFileSync(timingFile);
43
} catch (e) {
44
console.log(e);
45
console.log(
46
`'${timingFile}' missing. Run './run-tests.sh' with QUARTO_TEST_TIMING='${timingFile}'`,
47
);
48
Deno.exit(1);
49
}
50
51
// Get timed tests information
52
const lines = timingFileContent.trim().split("\n");
53
// Get all .test.ts files (including `smoke-all.test.ts`)
54
const currentTests = new Set(
55
[...expandGlobSync("**/*.test.ts", { globstar: true })].map((entry) =>
56
`./${relative(Deno.cwd(), entry.path)}`
57
),
58
);
59
60
// Get all smoke-all documents (Only resolve glob when it will be needed)
61
const currentSmokeFiles = new Set<string>(
62
detailedSmokeAll
63
? [
64
...expandGlobSync("docs/smoke-all/**/*.{md,qmd,ipynb}", {
65
globstar: true,
66
}),
67
]
68
// ignore file starting with `_`
69
.filter((entry) => /^[^_]/.test(basename(entry.path)))
70
.map((entry) => `${relative(Deno.cwd(), entry.path)}`)
71
: [],
72
);
73
74
const timedTests = new Set<string>();
75
const timedSmokeAllDocs = new Set<string>();
76
77
type Timing = {
78
real: number;
79
user: number;
80
sys: number;
81
};
82
type TestTiming = {
83
name: string;
84
timing: Timing;
85
};
86
87
const testTimings: TestTiming[] = [];
88
89
// Regex to match detailed smoke-all results
90
const RegSmokeAllFile = new RegExp("smoke\/smoke-all\.test\.ts -- (.*)$");
91
let dontUseDetailledSmokeAll = false;
92
93
// Creating a JSON for CI require smoke-all timed tests
94
if (
95
detailedSmokeAll &&
96
lines.filter((line) => RegSmokeAllFile.test(line.trim())).length == 0
97
) {
98
throw new Error(
99
`No smoke-all timed tests found in ${timingFile}. Run './run-tests.sh' with QUARTO_TEST_TIMING to create a new file, and pass another file with '--timing-file='`,
100
);
101
}
102
103
// Checking that timed tests still exists, otherwise log and exclude
104
for (let i = 0; i < lines.length; i += 2) {
105
const name = lines[i].trim();
106
// Ignore `./test.ts` as it is a helper file only
107
if (/(^|\/)test\.ts$/.test(name)) continue;
108
if (RegSmokeAllFile.test(name)) {
109
// Detailled smoke-all timed tests are found
110
if (!detailedSmokeAll) {
111
// Detailled tests are not used so they are ignored.
112
dontUseDetailledSmokeAll = true;
113
continue;
114
} else {
115
// checking smoke file existence
116
const smokeFile = name.split(" -- ")[1];
117
if (!currentSmokeFiles.has(smokeFile)) {
118
flags.verbose &&
119
console.log(
120
`Test ${name} in '${timingFile}' does not exists anymore. Update '${timingFile} with 'run ./run-tests.sh with QUARTO_TEST_TIMING='${timingFile}'`,
121
);
122
continue;
123
}
124
timedSmokeAllDocs.add(smokeFile);
125
}
126
} else {
127
// Regular smoke tests
128
if (!currentTests.has(name)) {
129
flags.verbose &&
130
console.log(
131
`Test ${name} in '${timingFile}' does not exists anymore. Update '${timingFile} with 'run ./run-tests.sh with QUARTO_TEST_TIMING='${timingFile}'`,
132
);
133
continue;
134
}
135
}
136
const timingStrs = lines[i + 1].trim().replaceAll(/ +/g, " ").split(" ");
137
const timing = {
138
real: Number(timingStrs[0]),
139
user: Number(timingStrs[2]),
140
sys: Number(timingStrs[4]),
141
};
142
testTimings.push({ name, timing });
143
timedTests.add(name);
144
}
145
let failed = false;
146
147
// console.log(
148
// testTimings.map((a) => (a.timing.real)).reduce((a, b) => a + b, 0),
149
// );
150
// console.log(testTimings.sort((a, b) => a.timing.real - b.timing.real));
151
// Deno.exit(0);
152
153
const buckets: TestTiming[][] = [];
154
const nBuckets = Number(flags.n) || navigator.hardwareConcurrency;
155
const bucketSizes = (new Array(nBuckets)).fill(0);
156
157
const argmin = (a: number[]): number => {
158
let best = -1, bestValue = Infinity;
159
for (let i = 0; i < a.length; ++i) {
160
if (a[i] < bestValue) {
161
best = i;
162
bestValue = a[i];
163
}
164
}
165
return best;
166
};
167
168
for (let i = 0; i < nBuckets; ++i) {
169
buckets.push([]);
170
}
171
172
// If we don't use detailled smoke-all, be sure to place smoke-all.tests.ts first for its own bucket
173
if (dontUseDetailledSmokeAll) {
174
failed = true;
175
flags.verbose &&
176
console.log(`${smokeAllTestFile} will run it is own bucket`);
177
buckets[0].push({
178
name: smokeAllTestFile,
179
timing: { real: 99999, user: 99999, sys: 99999 },
180
});
181
bucketSizes[0] += 99999;
182
}
183
// Add other test to the bucket will less overall timing
184
for (const timing of testTimings) {
185
const ix = argmin(bucketSizes);
186
buckets[ix].push(timing);
187
bucketSizes[ix] += timing.timing.real;
188
}
189
190
// Add to buckets un-timed tests
191
for (const currentTest of currentTests) {
192
let missingTests = new Set<string>();
193
// smoke-all.tests.ts is handled specifically
194
if (currentTest.match(/smoke-all\.test\.ts/)) {
195
if (detailedSmokeAll && !dontUseDetailledSmokeAll) {
196
for (const currentSmokeFile of currentSmokeFiles) {
197
if (!timedSmokeAllDocs.has(currentSmokeFile)) {
198
flags.verbose &&
199
console.log(
200
`Missing smoke-all docs '${currentSmokeFile}' in ${timingFile}`,
201
);
202
failed = true;
203
missingTests.add(`${smokeAllTestFile} -- ${currentSmokeFile}`);
204
}
205
}
206
}
207
} else if (!timedTests.has(currentTest)) {
208
flags.verbose &&
209
console.log(`Missing test '${currentTest}' in ${timingFile}`);
210
failed = true;
211
missingTests.add(currentTest);
212
}
213
if (missingTests.size !== 0) {
214
missingTests.forEach((missingTest) => {
215
// add missing timed tests, randomly to buckets
216
buckets[Math.floor(Math.random() * nBuckets)].push({
217
name: missingTest,
218
timing: { real: 0, user: 0, sys: 0 },
219
});
220
});
221
}
222
}
223
224
flags.verbose && console.log(`Will run in ${nBuckets} cores`);
225
// FIXME: Not sure this still applies after new smoke-all treatment
226
if (!failed && flags.verbose) {
227
console.log(
228
`Expected speedup: ${
229
(bucketSizes.reduce((a, b) => a + b, 0) / Math.max(...bucketSizes))
230
.toFixed(
231
2,
232
)
233
}`,
234
);
235
}
236
237
// DRY-RUN MODE
238
if (flags["dry-run"]) {
239
console.log(JSON.stringify(buckets, null, 2));
240
Deno.exit(0);
241
}
242
243
if (flags["json-for-ci"]) {
244
// JSON for CI matrix (GHA MODE)
245
flags.verbose && console.log("Buckets of tests to run in parallel");
246
const bucketSimple = buckets.map((bucket) => {
247
return bucket.map((tt) => {
248
tt.name = RegSmokeAllFile.test(tt.name)
249
? tt.name.split(" -- ")[1]
250
: tt.name;
251
return tt.name;
252
});
253
});
254
//flags.verbose && console.log(buckets.map((e) => e.length));
255
console.log(JSON.stringify(bucketSimple, null, 2));
256
} else {
257
// LOCAL EXECUTION ON CORES
258
console.log("Running `run-test.sh` in parallel... ");
259
Promise.all(buckets.map((bucket, i) => {
260
const cmd: string[] = ["./run-tests.sh"];
261
cmd.push(...bucket.map((tt) => tt.name));
262
return Deno.run({ cmd }).status();
263
})).then(() => {
264
console.log("Running `run-test.sh` in parallel... END");
265
});
266
}
267
268