@tryaura/aura-testkit 0.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/LICENSE +202 -0
- package/README.md +144 -0
- package/dist/index.d.ts +306 -0
- package/dist/index.js +2005 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2005 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { PassThrough, Readable } from "node:stream";
|
|
6
|
+
import { runCli } from "@tryaura/aura-cli";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { DEFAULT_HTTP_TIMEOUT_MS, MAX_HTTP_RESPONSE_BYTES, MAX_HTTP_TIMEOUT_MS } from "@tryaura/aura-sdk";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/base.js
|
|
11
|
+
var Diff = class {
|
|
12
|
+
diff(oldStr, newStr, options = {}) {
|
|
13
|
+
let callback;
|
|
14
|
+
if (typeof options === "function") {
|
|
15
|
+
callback = options;
|
|
16
|
+
options = {};
|
|
17
|
+
} else if ("callback" in options) callback = options.callback;
|
|
18
|
+
const oldString = this.castInput(oldStr, options);
|
|
19
|
+
const newString = this.castInput(newStr, options);
|
|
20
|
+
const oldTokens = this.removeEmpty(this.tokenize(oldString, options));
|
|
21
|
+
const newTokens = this.removeEmpty(this.tokenize(newString, options));
|
|
22
|
+
return this.diffWithOptionsObj(oldTokens, newTokens, options, callback);
|
|
23
|
+
}
|
|
24
|
+
diffWithOptionsObj(oldTokens, newTokens, options, callback) {
|
|
25
|
+
var _a;
|
|
26
|
+
const done = (value) => {
|
|
27
|
+
value = this.postProcess(value, options);
|
|
28
|
+
if (callback) {
|
|
29
|
+
setTimeout(function() {
|
|
30
|
+
callback(value);
|
|
31
|
+
}, 0);
|
|
32
|
+
return;
|
|
33
|
+
} else return value;
|
|
34
|
+
};
|
|
35
|
+
const newLen = newTokens.length, oldLen = oldTokens.length;
|
|
36
|
+
let editLength = 1;
|
|
37
|
+
let maxEditLength = newLen + oldLen;
|
|
38
|
+
if (options.maxEditLength != null) maxEditLength = Math.min(maxEditLength, options.maxEditLength);
|
|
39
|
+
const maxExecutionTime = (_a = options.timeout) !== null && _a !== void 0 ? _a : Infinity;
|
|
40
|
+
const abortAfterTimestamp = Date.now() + maxExecutionTime;
|
|
41
|
+
const bestPath = [{
|
|
42
|
+
oldPos: -1,
|
|
43
|
+
lastComponent: void 0
|
|
44
|
+
}];
|
|
45
|
+
let newPos = this.extractCommon(bestPath[0], newTokens, oldTokens, 0, options);
|
|
46
|
+
if (bestPath[0].oldPos + 1 >= oldLen && newPos + 1 >= newLen) return done(this.buildValues(bestPath[0].lastComponent, newTokens, oldTokens));
|
|
47
|
+
let minDiagonalToConsider = -Infinity, maxDiagonalToConsider = Infinity;
|
|
48
|
+
const execEditLength = () => {
|
|
49
|
+
for (let diagonalPath = Math.max(minDiagonalToConsider, -editLength); diagonalPath <= Math.min(maxDiagonalToConsider, editLength); diagonalPath += 2) {
|
|
50
|
+
let basePath;
|
|
51
|
+
const removePath = bestPath[diagonalPath - 1], addPath = bestPath[diagonalPath + 1];
|
|
52
|
+
if (removePath) bestPath[diagonalPath - 1] = void 0;
|
|
53
|
+
let canAdd = false;
|
|
54
|
+
if (addPath) {
|
|
55
|
+
const addPathNewPos = addPath.oldPos - diagonalPath;
|
|
56
|
+
canAdd = addPath && 0 <= addPathNewPos && addPathNewPos < newLen;
|
|
57
|
+
}
|
|
58
|
+
const canRemove = removePath && removePath.oldPos + 1 < oldLen;
|
|
59
|
+
if (!canAdd && !canRemove) {
|
|
60
|
+
bestPath[diagonalPath] = void 0;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!canRemove || canAdd && removePath.oldPos < addPath.oldPos) basePath = this.addToPath(addPath, true, false, 0, options);
|
|
64
|
+
else basePath = this.addToPath(removePath, false, true, 1, options);
|
|
65
|
+
newPos = this.extractCommon(basePath, newTokens, oldTokens, diagonalPath, options);
|
|
66
|
+
if (basePath.oldPos + 1 >= oldLen && newPos + 1 >= newLen) return done(this.buildValues(basePath.lastComponent, newTokens, oldTokens)) || true;
|
|
67
|
+
else {
|
|
68
|
+
bestPath[diagonalPath] = basePath;
|
|
69
|
+
if (basePath.oldPos + 1 >= oldLen) maxDiagonalToConsider = Math.min(maxDiagonalToConsider, diagonalPath - 1);
|
|
70
|
+
if (newPos + 1 >= newLen) minDiagonalToConsider = Math.max(minDiagonalToConsider, diagonalPath + 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
editLength++;
|
|
74
|
+
};
|
|
75
|
+
if (callback) (function exec() {
|
|
76
|
+
setTimeout(function() {
|
|
77
|
+
if (editLength > maxEditLength || Date.now() > abortAfterTimestamp) return callback(void 0);
|
|
78
|
+
if (!execEditLength()) exec();
|
|
79
|
+
}, 0);
|
|
80
|
+
})();
|
|
81
|
+
else while (editLength <= maxEditLength && Date.now() <= abortAfterTimestamp) {
|
|
82
|
+
const ret = execEditLength();
|
|
83
|
+
if (ret) return ret;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
addToPath(path, added, removed, oldPosInc, options) {
|
|
87
|
+
const last = path.lastComponent;
|
|
88
|
+
if (last && !options.oneChangePerToken && last.added === added && last.removed === removed) return {
|
|
89
|
+
oldPos: path.oldPos + oldPosInc,
|
|
90
|
+
lastComponent: {
|
|
91
|
+
count: last.count + 1,
|
|
92
|
+
added,
|
|
93
|
+
removed,
|
|
94
|
+
previousComponent: last.previousComponent
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
else return {
|
|
98
|
+
oldPos: path.oldPos + oldPosInc,
|
|
99
|
+
lastComponent: {
|
|
100
|
+
count: 1,
|
|
101
|
+
added,
|
|
102
|
+
removed,
|
|
103
|
+
previousComponent: last
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
extractCommon(basePath, newTokens, oldTokens, diagonalPath, options) {
|
|
108
|
+
const newLen = newTokens.length, oldLen = oldTokens.length;
|
|
109
|
+
let oldPos = basePath.oldPos, newPos = oldPos - diagonalPath, commonCount = 0;
|
|
110
|
+
while (newPos + 1 < newLen && oldPos + 1 < oldLen && this.equals(oldTokens[oldPos + 1], newTokens[newPos + 1], options)) {
|
|
111
|
+
newPos++;
|
|
112
|
+
oldPos++;
|
|
113
|
+
commonCount++;
|
|
114
|
+
if (options.oneChangePerToken) basePath.lastComponent = {
|
|
115
|
+
count: 1,
|
|
116
|
+
previousComponent: basePath.lastComponent,
|
|
117
|
+
added: false,
|
|
118
|
+
removed: false
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (commonCount && !options.oneChangePerToken) basePath.lastComponent = {
|
|
122
|
+
count: commonCount,
|
|
123
|
+
previousComponent: basePath.lastComponent,
|
|
124
|
+
added: false,
|
|
125
|
+
removed: false
|
|
126
|
+
};
|
|
127
|
+
basePath.oldPos = oldPos;
|
|
128
|
+
return newPos;
|
|
129
|
+
}
|
|
130
|
+
equals(left, right, options) {
|
|
131
|
+
if (options.comparator) return options.comparator(left, right);
|
|
132
|
+
else return left === right || !!options.ignoreCase && left.toLowerCase() === right.toLowerCase();
|
|
133
|
+
}
|
|
134
|
+
removeEmpty(array) {
|
|
135
|
+
const ret = [];
|
|
136
|
+
for (let i = 0; i < array.length; i++) if (array[i]) ret.push(array[i]);
|
|
137
|
+
return ret;
|
|
138
|
+
}
|
|
139
|
+
castInput(value, options) {
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
tokenize(value, options) {
|
|
143
|
+
return Array.from(value);
|
|
144
|
+
}
|
|
145
|
+
join(chars) {
|
|
146
|
+
return chars.join("");
|
|
147
|
+
}
|
|
148
|
+
postProcess(changeObjects, options) {
|
|
149
|
+
return changeObjects;
|
|
150
|
+
}
|
|
151
|
+
get useLongestToken() {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
buildValues(lastComponent, newTokens, oldTokens) {
|
|
155
|
+
const components = [];
|
|
156
|
+
let nextComponent;
|
|
157
|
+
while (lastComponent) {
|
|
158
|
+
components.push(lastComponent);
|
|
159
|
+
nextComponent = lastComponent.previousComponent;
|
|
160
|
+
delete lastComponent.previousComponent;
|
|
161
|
+
lastComponent = nextComponent;
|
|
162
|
+
}
|
|
163
|
+
components.reverse();
|
|
164
|
+
const componentLen = components.length;
|
|
165
|
+
let componentPos = 0, newPos = 0, oldPos = 0;
|
|
166
|
+
for (; componentPos < componentLen; componentPos++) {
|
|
167
|
+
const component = components[componentPos];
|
|
168
|
+
if (!component.removed) {
|
|
169
|
+
if (!component.added && this.useLongestToken) {
|
|
170
|
+
let value = newTokens.slice(newPos, newPos + component.count);
|
|
171
|
+
value = value.map(function(value, i) {
|
|
172
|
+
const oldValue = oldTokens[oldPos + i];
|
|
173
|
+
return oldValue.length > value.length ? oldValue : value;
|
|
174
|
+
});
|
|
175
|
+
component.value = this.join(value);
|
|
176
|
+
} else component.value = this.join(newTokens.slice(newPos, newPos + component.count));
|
|
177
|
+
newPos += component.count;
|
|
178
|
+
if (!component.added) oldPos += component.count;
|
|
179
|
+
} else {
|
|
180
|
+
component.value = this.join(oldTokens.slice(oldPos, oldPos + component.count));
|
|
181
|
+
oldPos += component.count;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return components;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/character.js
|
|
189
|
+
var CharacterDiff = class extends Diff {};
|
|
190
|
+
new CharacterDiff();
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/util/string.js
|
|
193
|
+
function longestCommonPrefix(str1, str2) {
|
|
194
|
+
let i;
|
|
195
|
+
for (i = 0; i < str1.length && i < str2.length; i++) if (str1[i] != str2[i]) return str1.slice(0, i);
|
|
196
|
+
return str1.slice(0, i);
|
|
197
|
+
}
|
|
198
|
+
function longestCommonSuffix(str1, str2) {
|
|
199
|
+
let i;
|
|
200
|
+
if (!str1 || !str2 || str1[str1.length - 1] != str2[str2.length - 1]) return "";
|
|
201
|
+
for (i = 0; i < str1.length && i < str2.length; i++) if (str1[str1.length - (i + 1)] != str2[str2.length - (i + 1)]) return str1.slice(-i);
|
|
202
|
+
return str1.slice(-i);
|
|
203
|
+
}
|
|
204
|
+
function replacePrefix(string, oldPrefix, newPrefix) {
|
|
205
|
+
if (string.slice(0, oldPrefix.length) != oldPrefix) throw Error(`string ${JSON.stringify(string)} doesn't start with prefix ${JSON.stringify(oldPrefix)}; this is a bug`);
|
|
206
|
+
return newPrefix + string.slice(oldPrefix.length);
|
|
207
|
+
}
|
|
208
|
+
function replaceSuffix(string, oldSuffix, newSuffix) {
|
|
209
|
+
if (!oldSuffix) return string + newSuffix;
|
|
210
|
+
if (string.slice(-oldSuffix.length) != oldSuffix) throw Error(`string ${JSON.stringify(string)} doesn't end with suffix ${JSON.stringify(oldSuffix)}; this is a bug`);
|
|
211
|
+
return string.slice(0, -oldSuffix.length) + newSuffix;
|
|
212
|
+
}
|
|
213
|
+
function removePrefix(string, oldPrefix) {
|
|
214
|
+
return replacePrefix(string, oldPrefix, "");
|
|
215
|
+
}
|
|
216
|
+
function removeSuffix(string, oldSuffix) {
|
|
217
|
+
return replaceSuffix(string, oldSuffix, "");
|
|
218
|
+
}
|
|
219
|
+
function maximumOverlap(string1, string2) {
|
|
220
|
+
return string2.slice(0, overlapCount(string1, string2));
|
|
221
|
+
}
|
|
222
|
+
function overlapCount(a, b) {
|
|
223
|
+
let startA = 0;
|
|
224
|
+
if (a.length > b.length) startA = a.length - b.length;
|
|
225
|
+
let endB = b.length;
|
|
226
|
+
if (a.length < b.length) endB = a.length;
|
|
227
|
+
const map = Array(endB);
|
|
228
|
+
let k = 0;
|
|
229
|
+
map[0] = 0;
|
|
230
|
+
for (let j = 1; j < endB; j++) {
|
|
231
|
+
if (b[j] == b[k]) map[j] = map[k];
|
|
232
|
+
else map[j] = k;
|
|
233
|
+
while (k > 0 && b[j] != b[k]) k = map[k];
|
|
234
|
+
if (b[j] == b[k]) k++;
|
|
235
|
+
}
|
|
236
|
+
k = 0;
|
|
237
|
+
for (let i = startA; i < a.length; i++) {
|
|
238
|
+
while (k > 0 && a[i] != b[k]) k = map[k];
|
|
239
|
+
if (a[i] == b[k]) k++;
|
|
240
|
+
}
|
|
241
|
+
return k;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Split a string into segments using a word segmenter, merging consecutive
|
|
245
|
+
* segments if they are both whitespace segments. Whitespace segments can
|
|
246
|
+
* appear adjacent to one another for two reasons:
|
|
247
|
+
* - newlines always get their own segment
|
|
248
|
+
* - where a diacritic is attached to a whitespace character in the text, the
|
|
249
|
+
* segment ends after the diacritic, so e.g. " \u0300 " becomes two segments.
|
|
250
|
+
* This function therefore runs the segmenter's .segment() method and then
|
|
251
|
+
* merges consecutive segments of whitespace into a single part.
|
|
252
|
+
*/
|
|
253
|
+
function segment(string, segmenter) {
|
|
254
|
+
const parts = [];
|
|
255
|
+
for (const segmentObj of Array.from(segmenter.segment(string))) {
|
|
256
|
+
const segment = segmentObj.segment;
|
|
257
|
+
if (parts.length && /\s/.test(parts[parts.length - 1]) && /\s/.test(segment)) parts[parts.length - 1] += segment;
|
|
258
|
+
else parts.push(segment);
|
|
259
|
+
}
|
|
260
|
+
return parts;
|
|
261
|
+
}
|
|
262
|
+
function trailingWs(string, segmenter) {
|
|
263
|
+
if (segmenter) return leadingAndTrailingWs(string, segmenter)[1];
|
|
264
|
+
let i;
|
|
265
|
+
for (i = string.length - 1; i >= 0; i--) if (!string[i].match(/\s/)) break;
|
|
266
|
+
return string.substring(i + 1);
|
|
267
|
+
}
|
|
268
|
+
function leadingWs(string, segmenter) {
|
|
269
|
+
if (segmenter) return leadingAndTrailingWs(string, segmenter)[0];
|
|
270
|
+
const match = string.match(/^\s*/);
|
|
271
|
+
return match ? match[0] : "";
|
|
272
|
+
}
|
|
273
|
+
function leadingAndTrailingWs(string, segmenter) {
|
|
274
|
+
if (!segmenter) return [leadingWs(string), trailingWs(string)];
|
|
275
|
+
if (segmenter.resolvedOptions().granularity != "word") throw new Error("The segmenter passed must have a granularity of \"word\"");
|
|
276
|
+
const segments = segment(string, segmenter);
|
|
277
|
+
const firstSeg = segments[0];
|
|
278
|
+
const lastSeg = segments[segments.length - 1];
|
|
279
|
+
return [/\s/.test(firstSeg) ? firstSeg : "", /\s/.test(lastSeg) ? lastSeg : ""];
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/word.js
|
|
283
|
+
const extendedWordChars = "a-zA-Z0-9_\\u{AD}\\u{C0}-\\u{D6}\\u{D8}-\\u{F6}\\u{F8}-\\u{2C6}\\u{2C8}-\\u{2D7}\\u{2DE}-\\u{2FF}\\u{1E00}-\\u{1EFF}";
|
|
284
|
+
const tokenizeIncludingWhitespace = new RegExp(`[${extendedWordChars}]+|\\s+|[^${extendedWordChars}]`, "ug");
|
|
285
|
+
var WordDiff = class extends Diff {
|
|
286
|
+
equals(left, right, options) {
|
|
287
|
+
if (options.ignoreCase) {
|
|
288
|
+
left = left.toLowerCase();
|
|
289
|
+
right = right.toLowerCase();
|
|
290
|
+
}
|
|
291
|
+
return left.trim() === right.trim();
|
|
292
|
+
}
|
|
293
|
+
tokenize(value, options = {}) {
|
|
294
|
+
let parts;
|
|
295
|
+
if (options.intlSegmenter) {
|
|
296
|
+
const segmenter = options.intlSegmenter;
|
|
297
|
+
if (segmenter.resolvedOptions().granularity != "word") throw new Error("The segmenter passed must have a granularity of \"word\"");
|
|
298
|
+
parts = segment(value, segmenter);
|
|
299
|
+
} else parts = value.match(tokenizeIncludingWhitespace) || [];
|
|
300
|
+
const tokens = [];
|
|
301
|
+
let prevPart = null;
|
|
302
|
+
parts.forEach((part) => {
|
|
303
|
+
if (/\s/.test(part)) {
|
|
304
|
+
if (prevPart == null) tokens.push(part);
|
|
305
|
+
else tokens.push(tokens.pop() + part);
|
|
306
|
+
} else if (prevPart != null && /\s/.test(prevPart)) {
|
|
307
|
+
if (tokens[tokens.length - 1] == prevPart) tokens.push(tokens.pop() + part);
|
|
308
|
+
else tokens.push(prevPart + part);
|
|
309
|
+
} else tokens.push(part);
|
|
310
|
+
prevPart = part;
|
|
311
|
+
});
|
|
312
|
+
return tokens;
|
|
313
|
+
}
|
|
314
|
+
join(tokens) {
|
|
315
|
+
return tokens.map((token, i) => {
|
|
316
|
+
if (i == 0) return token;
|
|
317
|
+
else return token.replace(/^\s+/, "");
|
|
318
|
+
}).join("");
|
|
319
|
+
}
|
|
320
|
+
postProcess(changes, options) {
|
|
321
|
+
if (!changes || options.oneChangePerToken) return changes;
|
|
322
|
+
let lastKeep = null;
|
|
323
|
+
let insertion = null;
|
|
324
|
+
let deletion = null;
|
|
325
|
+
changes.forEach((change) => {
|
|
326
|
+
if (change.added) insertion = change;
|
|
327
|
+
else if (change.removed) deletion = change;
|
|
328
|
+
else {
|
|
329
|
+
if (insertion || deletion) dedupeWhitespaceInChangeObjects(lastKeep, deletion, insertion, change, options.intlSegmenter);
|
|
330
|
+
lastKeep = change;
|
|
331
|
+
insertion = null;
|
|
332
|
+
deletion = null;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
if (insertion || deletion) dedupeWhitespaceInChangeObjects(lastKeep, deletion, insertion, null, options.intlSegmenter);
|
|
336
|
+
return changes;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
new WordDiff();
|
|
340
|
+
function dedupeWhitespaceInChangeObjects(startKeep, deletion, insertion, endKeep, segmenter) {
|
|
341
|
+
if (deletion && insertion) {
|
|
342
|
+
const [oldWsPrefix, oldWsSuffix] = leadingAndTrailingWs(deletion.value, segmenter);
|
|
343
|
+
const [newWsPrefix, newWsSuffix] = leadingAndTrailingWs(insertion.value, segmenter);
|
|
344
|
+
if (startKeep) {
|
|
345
|
+
const commonWsPrefix = longestCommonPrefix(oldWsPrefix, newWsPrefix);
|
|
346
|
+
startKeep.value = replaceSuffix(startKeep.value, newWsPrefix, commonWsPrefix);
|
|
347
|
+
deletion.value = removePrefix(deletion.value, commonWsPrefix);
|
|
348
|
+
insertion.value = removePrefix(insertion.value, commonWsPrefix);
|
|
349
|
+
}
|
|
350
|
+
if (endKeep) {
|
|
351
|
+
const commonWsSuffix = longestCommonSuffix(oldWsSuffix, newWsSuffix);
|
|
352
|
+
endKeep.value = replacePrefix(endKeep.value, newWsSuffix, commonWsSuffix);
|
|
353
|
+
deletion.value = removeSuffix(deletion.value, commonWsSuffix);
|
|
354
|
+
insertion.value = removeSuffix(insertion.value, commonWsSuffix);
|
|
355
|
+
}
|
|
356
|
+
} else if (insertion) {
|
|
357
|
+
if (startKeep) {
|
|
358
|
+
const ws = leadingWs(insertion.value, segmenter);
|
|
359
|
+
insertion.value = insertion.value.substring(ws.length);
|
|
360
|
+
}
|
|
361
|
+
if (endKeep) {
|
|
362
|
+
const ws = leadingWs(endKeep.value, segmenter);
|
|
363
|
+
endKeep.value = endKeep.value.substring(ws.length);
|
|
364
|
+
}
|
|
365
|
+
} else if (startKeep && endKeep) {
|
|
366
|
+
const newWsFull = leadingWs(endKeep.value, segmenter), [delWsStart, delWsEnd] = leadingAndTrailingWs(deletion.value, segmenter);
|
|
367
|
+
const newWsStart = longestCommonPrefix(newWsFull, delWsStart);
|
|
368
|
+
deletion.value = removePrefix(deletion.value, newWsStart);
|
|
369
|
+
const newWsEnd = longestCommonSuffix(removePrefix(newWsFull, newWsStart), delWsEnd);
|
|
370
|
+
deletion.value = removeSuffix(deletion.value, newWsEnd);
|
|
371
|
+
endKeep.value = replacePrefix(endKeep.value, newWsFull, newWsEnd);
|
|
372
|
+
startKeep.value = replaceSuffix(startKeep.value, newWsFull, newWsFull.slice(0, newWsFull.length - newWsEnd.length));
|
|
373
|
+
} else if (endKeep) {
|
|
374
|
+
const endKeepWsPrefix = leadingWs(endKeep.value, segmenter);
|
|
375
|
+
const overlap = maximumOverlap(trailingWs(deletion.value, segmenter), endKeepWsPrefix);
|
|
376
|
+
deletion.value = removeSuffix(deletion.value, overlap);
|
|
377
|
+
} else if (startKeep) {
|
|
378
|
+
const overlap = maximumOverlap(trailingWs(startKeep.value, segmenter), leadingWs(deletion.value, segmenter));
|
|
379
|
+
deletion.value = removePrefix(deletion.value, overlap);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
var WordsWithSpaceDiff = class extends Diff {
|
|
383
|
+
tokenize(value) {
|
|
384
|
+
const regex = new RegExp(`(\\r?\\n)|[${extendedWordChars}]+|[^\\S\\n\\r]+|[^${extendedWordChars}]`, "ug");
|
|
385
|
+
return value.match(regex) || [];
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
new WordsWithSpaceDiff();
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/line.js
|
|
391
|
+
var LineDiff = class extends Diff {
|
|
392
|
+
constructor() {
|
|
393
|
+
super(...arguments);
|
|
394
|
+
this.tokenize = tokenize;
|
|
395
|
+
}
|
|
396
|
+
equals(left, right, options) {
|
|
397
|
+
if (options.ignoreWhitespace) {
|
|
398
|
+
if (!options.newlineIsToken || !left.includes("\n")) left = left.trim();
|
|
399
|
+
if (!options.newlineIsToken || !right.includes("\n")) right = right.trim();
|
|
400
|
+
} else if (options.ignoreNewlineAtEof && !options.newlineIsToken) {
|
|
401
|
+
if (left.endsWith("\n")) left = left.slice(0, -1);
|
|
402
|
+
if (right.endsWith("\n")) right = right.slice(0, -1);
|
|
403
|
+
}
|
|
404
|
+
return super.equals(left, right, options);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
const lineDiff = new LineDiff();
|
|
408
|
+
function diffLines(oldStr, newStr, options) {
|
|
409
|
+
return lineDiff.diff(oldStr, newStr, options);
|
|
410
|
+
}
|
|
411
|
+
function tokenize(value, options) {
|
|
412
|
+
if (options.stripTrailingCr) value = value.replace(/\r\n/g, "\n");
|
|
413
|
+
const retLines = [], linesAndNewlines = value.split(/(\n|\r\n)/);
|
|
414
|
+
if (!linesAndNewlines[linesAndNewlines.length - 1]) linesAndNewlines.pop();
|
|
415
|
+
for (let i = 0; i < linesAndNewlines.length; i++) {
|
|
416
|
+
const line = linesAndNewlines[i];
|
|
417
|
+
if (i % 2 && !options.newlineIsToken) retLines[retLines.length - 1] += line;
|
|
418
|
+
else retLines.push(line);
|
|
419
|
+
}
|
|
420
|
+
return retLines;
|
|
421
|
+
}
|
|
422
|
+
//#endregion
|
|
423
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/sentence.js
|
|
424
|
+
function isSentenceEndPunct(char) {
|
|
425
|
+
return char == "." || char == "!" || char == "?";
|
|
426
|
+
}
|
|
427
|
+
var SentenceDiff = class extends Diff {
|
|
428
|
+
tokenize(value) {
|
|
429
|
+
var _a;
|
|
430
|
+
const result = [];
|
|
431
|
+
let tokenStartI = 0;
|
|
432
|
+
for (let i = 0; i < value.length; i++) {
|
|
433
|
+
if (i == value.length - 1) {
|
|
434
|
+
result.push(value.slice(tokenStartI));
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
if (isSentenceEndPunct(value[i]) && value[i + 1].match(/\s/)) {
|
|
438
|
+
result.push(value.slice(tokenStartI, i + 1));
|
|
439
|
+
i = tokenStartI = i + 1;
|
|
440
|
+
while ((_a = value[i + 1]) === null || _a === void 0 ? void 0 : _a.match(/\s/)) i++;
|
|
441
|
+
result.push(value.slice(tokenStartI, i + 1));
|
|
442
|
+
tokenStartI = i + 1;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
new SentenceDiff();
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/css.js
|
|
451
|
+
var CssDiff = class extends Diff {
|
|
452
|
+
tokenize(value) {
|
|
453
|
+
return value.split(/([{}:;,]|\s+)/);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
new CssDiff();
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/json.js
|
|
459
|
+
var JsonDiff = class extends Diff {
|
|
460
|
+
constructor() {
|
|
461
|
+
super(...arguments);
|
|
462
|
+
this.tokenize = tokenize;
|
|
463
|
+
}
|
|
464
|
+
get useLongestToken() {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
castInput(value, options) {
|
|
468
|
+
const { undefinedReplacement, stringifyReplacer = (k, v) => typeof v === "undefined" ? undefinedReplacement : v } = options;
|
|
469
|
+
return typeof value === "string" ? value : JSON.stringify(canonicalize(value, null, null, stringifyReplacer), null, " ");
|
|
470
|
+
}
|
|
471
|
+
equals(left, right, options) {
|
|
472
|
+
return super.equals(left.replace(/,([\r\n])/g, "$1"), right.replace(/,([\r\n])/g, "$1"), options);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
new JsonDiff();
|
|
476
|
+
function canonicalize(obj, stack, replacementStack, replacer, key) {
|
|
477
|
+
stack = stack || [];
|
|
478
|
+
replacementStack = replacementStack || [];
|
|
479
|
+
if (replacer) obj = replacer(key === void 0 ? "" : key, obj);
|
|
480
|
+
let i;
|
|
481
|
+
for (i = 0; i < stack.length; i += 1) if (stack[i] === obj) return replacementStack[i];
|
|
482
|
+
let canonicalizedObj;
|
|
483
|
+
if ("[object Array]" === Object.prototype.toString.call(obj)) {
|
|
484
|
+
stack.push(obj);
|
|
485
|
+
canonicalizedObj = new Array(obj.length);
|
|
486
|
+
replacementStack.push(canonicalizedObj);
|
|
487
|
+
for (i = 0; i < obj.length; i += 1) canonicalizedObj[i] = canonicalize(obj[i], stack, replacementStack, replacer, String(i));
|
|
488
|
+
stack.pop();
|
|
489
|
+
replacementStack.pop();
|
|
490
|
+
return canonicalizedObj;
|
|
491
|
+
}
|
|
492
|
+
if (obj && obj.toJSON) obj = obj.toJSON();
|
|
493
|
+
if (typeof obj === "object" && obj !== null) {
|
|
494
|
+
stack.push(obj);
|
|
495
|
+
canonicalizedObj = {};
|
|
496
|
+
replacementStack.push(canonicalizedObj);
|
|
497
|
+
const sortedKeys = [];
|
|
498
|
+
let key;
|
|
499
|
+
for (key in obj)
|
|
500
|
+
/* istanbul ignore else */
|
|
501
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) sortedKeys.push(key);
|
|
502
|
+
sortedKeys.sort();
|
|
503
|
+
for (i = 0; i < sortedKeys.length; i += 1) {
|
|
504
|
+
key = sortedKeys[i];
|
|
505
|
+
canonicalizedObj[key] = canonicalize(obj[key], stack, replacementStack, replacer, key);
|
|
506
|
+
}
|
|
507
|
+
stack.pop();
|
|
508
|
+
replacementStack.pop();
|
|
509
|
+
} else canonicalizedObj = obj;
|
|
510
|
+
return canonicalizedObj;
|
|
511
|
+
}
|
|
512
|
+
//#endregion
|
|
513
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/array.js
|
|
514
|
+
var ArrayDiff = class extends Diff {
|
|
515
|
+
tokenize(value) {
|
|
516
|
+
return value.slice();
|
|
517
|
+
}
|
|
518
|
+
join(value) {
|
|
519
|
+
return value;
|
|
520
|
+
}
|
|
521
|
+
removeEmpty(value) {
|
|
522
|
+
return value;
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
new ArrayDiff();
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/patch/create.js
|
|
528
|
+
/**
|
|
529
|
+
* Returns true if the filename contains characters that require C-style
|
|
530
|
+
* quoting (as used by Git and GNU diffutils in diff output).
|
|
531
|
+
*/
|
|
532
|
+
function needsQuoting(s) {
|
|
533
|
+
for (let i = 0; i < s.length; i++) if (s[i] < " " || s[i] > "~" || s[i] === "\"" || s[i] === "\\") return true;
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* C-style quotes a filename, encoding special characters as escape sequences
|
|
538
|
+
* and non-ASCII bytes as octal escapes. This is the inverse of
|
|
539
|
+
* `parseQuotedFileName` in parse.ts.
|
|
540
|
+
*
|
|
541
|
+
* Non-ASCII bytes are encoded as UTF-8 before being emitted as octal escapes.
|
|
542
|
+
* This matches the behaviour of both Git and GNU diffutils, which always emit
|
|
543
|
+
* UTF-8 octal escapes regardless of the underlying filesystem encoding (e.g.
|
|
544
|
+
* Git for Windows converts from NTFS's UTF-16 to UTF-8 internally).
|
|
545
|
+
*
|
|
546
|
+
* If the filename doesn't need quoting, returns it as-is.
|
|
547
|
+
*/
|
|
548
|
+
function quoteFileNameIfNeeded(s) {
|
|
549
|
+
if (!needsQuoting(s)) return s;
|
|
550
|
+
let result = "\"";
|
|
551
|
+
const bytes = new TextEncoder().encode(s);
|
|
552
|
+
let i = 0;
|
|
553
|
+
while (i < bytes.length) {
|
|
554
|
+
const b = bytes[i];
|
|
555
|
+
if (b === 7) result += "\\a";
|
|
556
|
+
else if (b === 8) result += "\\b";
|
|
557
|
+
else if (b === 9) result += "\\t";
|
|
558
|
+
else if (b === 10) result += "\\n";
|
|
559
|
+
else if (b === 11) result += "\\v";
|
|
560
|
+
else if (b === 12) result += "\\f";
|
|
561
|
+
else if (b === 13) result += "\\r";
|
|
562
|
+
else if (b === 34) result += "\\\"";
|
|
563
|
+
else if (b === 92) result += "\\\\";
|
|
564
|
+
else if (b >= 32 && b <= 126) result += String.fromCharCode(b);
|
|
565
|
+
else result += "\\" + b.toString(8).padStart(3, "0");
|
|
566
|
+
i++;
|
|
567
|
+
}
|
|
568
|
+
result += "\"";
|
|
569
|
+
return result;
|
|
570
|
+
}
|
|
571
|
+
const INCLUDE_HEADERS = {
|
|
572
|
+
includeIndex: true,
|
|
573
|
+
includeUnderline: true,
|
|
574
|
+
includeFileHeaders: true
|
|
575
|
+
};
|
|
576
|
+
function structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) {
|
|
577
|
+
let optionsObj;
|
|
578
|
+
if (!options) optionsObj = {};
|
|
579
|
+
else if (typeof options === "function") optionsObj = { callback: options };
|
|
580
|
+
else optionsObj = options;
|
|
581
|
+
if (typeof optionsObj.context === "undefined") optionsObj.context = 4;
|
|
582
|
+
const context = optionsObj.context;
|
|
583
|
+
if (optionsObj.newlineIsToken) throw new Error("newlineIsToken may not be used with patch-generation functions, only with diffing functions");
|
|
584
|
+
if (!optionsObj.callback) return diffLinesResultToPatch(diffLines(oldStr, newStr, optionsObj));
|
|
585
|
+
else {
|
|
586
|
+
const { callback } = optionsObj;
|
|
587
|
+
diffLines(oldStr, newStr, Object.assign(Object.assign({}, optionsObj), { callback: (diff) => {
|
|
588
|
+
const patch = diffLinesResultToPatch(diff);
|
|
589
|
+
callback(patch);
|
|
590
|
+
} }));
|
|
591
|
+
}
|
|
592
|
+
function diffLinesResultToPatch(diff) {
|
|
593
|
+
if (!diff) return;
|
|
594
|
+
diff.push({
|
|
595
|
+
value: "",
|
|
596
|
+
lines: []
|
|
597
|
+
});
|
|
598
|
+
function contextLines(lines) {
|
|
599
|
+
return lines.map(function(entry) {
|
|
600
|
+
return " " + entry;
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
const hunks = [];
|
|
604
|
+
let oldRangeStart = 0, newRangeStart = 0, curRange = [], oldLine = 1, newLine = 1;
|
|
605
|
+
for (let i = 0; i < diff.length; i++) {
|
|
606
|
+
const current = diff[i], lines = current.lines || splitLines(current.value);
|
|
607
|
+
current.lines = lines;
|
|
608
|
+
if (current.added || current.removed) {
|
|
609
|
+
if (!oldRangeStart) {
|
|
610
|
+
const prev = diff[i - 1];
|
|
611
|
+
oldRangeStart = oldLine;
|
|
612
|
+
newRangeStart = newLine;
|
|
613
|
+
if (prev) {
|
|
614
|
+
curRange = context > 0 ? contextLines(prev.lines.slice(-context)) : [];
|
|
615
|
+
oldRangeStart -= curRange.length;
|
|
616
|
+
newRangeStart -= curRange.length;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const line of lines) curRange.push((current.added ? "+" : "-") + line);
|
|
620
|
+
if (current.added) newLine += lines.length;
|
|
621
|
+
else oldLine += lines.length;
|
|
622
|
+
} else {
|
|
623
|
+
if (oldRangeStart) {
|
|
624
|
+
if (lines.length <= context * 2 && i < diff.length - 2) for (const line of contextLines(lines)) curRange.push(line);
|
|
625
|
+
else {
|
|
626
|
+
const contextSize = Math.min(lines.length, context);
|
|
627
|
+
for (const line of contextLines(lines.slice(0, contextSize))) curRange.push(line);
|
|
628
|
+
const hunk = {
|
|
629
|
+
oldStart: oldRangeStart,
|
|
630
|
+
oldLines: oldLine - oldRangeStart + contextSize,
|
|
631
|
+
newStart: newRangeStart,
|
|
632
|
+
newLines: newLine - newRangeStart + contextSize,
|
|
633
|
+
lines: curRange
|
|
634
|
+
};
|
|
635
|
+
hunks.push(hunk);
|
|
636
|
+
oldRangeStart = 0;
|
|
637
|
+
newRangeStart = 0;
|
|
638
|
+
curRange = [];
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
oldLine += lines.length;
|
|
642
|
+
newLine += lines.length;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
for (const hunk of hunks) for (let i = 0; i < hunk.lines.length; i++) if (hunk.lines[i].endsWith("\n")) hunk.lines[i] = hunk.lines[i].slice(0, -1);
|
|
646
|
+
else {
|
|
647
|
+
hunk.lines.splice(i + 1, 0, "\");
|
|
648
|
+
i++;
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
oldFileName,
|
|
652
|
+
newFileName,
|
|
653
|
+
oldHeader,
|
|
654
|
+
newHeader,
|
|
655
|
+
hunks
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* creates a unified diff patch.
|
|
661
|
+
*
|
|
662
|
+
* @param patch either a single structured patch object (as returned by `structuredPatch`) or an
|
|
663
|
+
* array of them (as returned by `parsePatch`).
|
|
664
|
+
* @param headerOptions behaves the same as the `headerOptions` option of `createTwoFilesPatch`.
|
|
665
|
+
* Ignored for patches where `isGit` is `true`.
|
|
666
|
+
*
|
|
667
|
+
* When a patch has `isGit: true`, `formatPatch` output is changed to more closely match Git's
|
|
668
|
+
* output: it emits a `diff --git` header, emits Git extended headers as appropriate based on
|
|
669
|
+
* properties like `isRename`, `isCreate`, `newMode`, etc, and will omit `---`/`+++` file
|
|
670
|
+
* headers for patches with no hunks (e.g. renames without content changes).
|
|
671
|
+
*/
|
|
672
|
+
function formatPatch(patch, headerOptions) {
|
|
673
|
+
var _a, _b, _c, _d, _e, _f;
|
|
674
|
+
if (!headerOptions) headerOptions = INCLUDE_HEADERS;
|
|
675
|
+
if (Array.isArray(patch)) {
|
|
676
|
+
if (patch.length > 1 && !headerOptions.includeFileHeaders && !patch.every((p) => p.isGit)) throw new Error("Cannot omit file headers on a multi-file patch. (The result would be unparseable; how would a tool trying to apply the patch know which changes are to which file?)");
|
|
677
|
+
return patch.map((p) => formatPatch(p, headerOptions)).join("\n");
|
|
678
|
+
}
|
|
679
|
+
const ret = [];
|
|
680
|
+
if (patch.isGit) {
|
|
681
|
+
headerOptions = INCLUDE_HEADERS;
|
|
682
|
+
if (!patch.oldFileName) throw new Error("oldFileName must be specified for Git patches");
|
|
683
|
+
if (!patch.newFileName) throw new Error("newFileName must be specified for Git patches");
|
|
684
|
+
let gitOldName = patch.oldFileName;
|
|
685
|
+
let gitNewName = patch.newFileName;
|
|
686
|
+
if (patch.isCreate && gitOldName === "/dev/null") gitOldName = gitNewName.replace(/^b\//, "a/");
|
|
687
|
+
else if (patch.isDelete && gitNewName === "/dev/null") gitNewName = gitOldName.replace(/^a\//, "b/");
|
|
688
|
+
ret.push("diff --git " + quoteFileNameIfNeeded(gitOldName) + " " + quoteFileNameIfNeeded(gitNewName));
|
|
689
|
+
if (patch.isDelete) ret.push("deleted file mode " + ((_a = patch.oldMode) !== null && _a !== void 0 ? _a : "100644"));
|
|
690
|
+
if (patch.isCreate) ret.push("new file mode " + ((_b = patch.newMode) !== null && _b !== void 0 ? _b : "100644"));
|
|
691
|
+
if (patch.oldMode && patch.newMode && !patch.isDelete && !patch.isCreate) {
|
|
692
|
+
ret.push("old mode " + patch.oldMode);
|
|
693
|
+
ret.push("new mode " + patch.newMode);
|
|
694
|
+
}
|
|
695
|
+
if (patch.isRename) {
|
|
696
|
+
ret.push("rename from " + quoteFileNameIfNeeded(((_c = patch.oldFileName) !== null && _c !== void 0 ? _c : "").replace(/^a\//, "")));
|
|
697
|
+
ret.push("rename to " + quoteFileNameIfNeeded(((_d = patch.newFileName) !== null && _d !== void 0 ? _d : "").replace(/^b\//, "")));
|
|
698
|
+
}
|
|
699
|
+
if (patch.isCopy) {
|
|
700
|
+
ret.push("copy from " + quoteFileNameIfNeeded(((_e = patch.oldFileName) !== null && _e !== void 0 ? _e : "").replace(/^a\//, "")));
|
|
701
|
+
ret.push("copy to " + quoteFileNameIfNeeded(((_f = patch.newFileName) !== null && _f !== void 0 ? _f : "").replace(/^b\//, "")));
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
if (headerOptions.includeIndex && patch.oldFileName == patch.newFileName && patch.oldFileName !== void 0) ret.push("Index: " + patch.oldFileName);
|
|
705
|
+
if (headerOptions.includeUnderline) ret.push("===================================================================");
|
|
706
|
+
}
|
|
707
|
+
const hasHunks = patch.hunks.length > 0;
|
|
708
|
+
if (headerOptions.includeFileHeaders && patch.oldFileName !== void 0 && patch.newFileName !== void 0 && (!patch.isGit || hasHunks)) {
|
|
709
|
+
ret.push("--- " + quoteFileNameIfNeeded(patch.oldFileName) + (patch.oldHeader ? " " + patch.oldHeader : ""));
|
|
710
|
+
ret.push("+++ " + quoteFileNameIfNeeded(patch.newFileName) + (patch.newHeader ? " " + patch.newHeader : ""));
|
|
711
|
+
}
|
|
712
|
+
for (let i = 0; i < patch.hunks.length; i++) {
|
|
713
|
+
const hunk = patch.hunks[i];
|
|
714
|
+
const oldStart = hunk.oldLines === 0 ? hunk.oldStart - 1 : hunk.oldStart;
|
|
715
|
+
const newStart = hunk.newLines === 0 ? hunk.newStart - 1 : hunk.newStart;
|
|
716
|
+
ret.push("@@ -" + oldStart + "," + hunk.oldLines + " +" + newStart + "," + hunk.newLines + " @@");
|
|
717
|
+
for (const line of hunk.lines) ret.push(line);
|
|
718
|
+
}
|
|
719
|
+
return ret.join("\n") + "\n";
|
|
720
|
+
}
|
|
721
|
+
function createTwoFilesPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options) {
|
|
722
|
+
if (typeof options === "function") options = { callback: options };
|
|
723
|
+
if (!(options === null || options === void 0 ? void 0 : options.callback)) {
|
|
724
|
+
const patchObj = structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, options);
|
|
725
|
+
if (!patchObj) return;
|
|
726
|
+
return formatPatch(patchObj, options === null || options === void 0 ? void 0 : options.headerOptions);
|
|
727
|
+
} else {
|
|
728
|
+
const { callback } = options;
|
|
729
|
+
structuredPatch(oldFileName, newFileName, oldStr, newStr, oldHeader, newHeader, Object.assign(Object.assign({}, options), { callback: (patchObj) => {
|
|
730
|
+
if (!patchObj) callback(void 0);
|
|
731
|
+
else callback(formatPatch(patchObj, options.headerOptions));
|
|
732
|
+
} }));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Split `text` into an array of lines, including the trailing newline character (where present)
|
|
737
|
+
*/
|
|
738
|
+
function splitLines(text) {
|
|
739
|
+
const hasTrailingNl = text.endsWith("\n");
|
|
740
|
+
const result = text.split("\n").map((line) => line + "\n");
|
|
741
|
+
if (hasTrailingNl) result.pop();
|
|
742
|
+
else result.push(result.pop().slice(0, -1));
|
|
743
|
+
return result;
|
|
744
|
+
}
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region src/filesystem.ts
|
|
747
|
+
const NULL_PATH = "/dev/null";
|
|
748
|
+
const UTF8 = new TextDecoder("utf8", { fatal: true });
|
|
749
|
+
async function captureFilesystem(seed) {
|
|
750
|
+
const entries = /* @__PURE__ */ new Map();
|
|
751
|
+
await Promise.all([captureDirectory(seed.homeDir, seed.homeDir, "<HOME>", entries), captureDirectory(seed.workspaceDir, seed.workspaceDir, "<WORKSPACE>", entries)]);
|
|
752
|
+
return entries;
|
|
753
|
+
}
|
|
754
|
+
function diffFilesystem(before, after, normalize) {
|
|
755
|
+
const paths = [.../* @__PURE__ */ new Set([...before.keys(), ...after.keys()])].sort();
|
|
756
|
+
const diffs = [];
|
|
757
|
+
for (const path of paths) {
|
|
758
|
+
const previous = before.get(path);
|
|
759
|
+
const current = after.get(path);
|
|
760
|
+
if (equalEntry(previous, current)) continue;
|
|
761
|
+
const status = changeStatus(previous, current);
|
|
762
|
+
const patch = createTwoFilesPatch(previous === void 0 ? NULL_PATH : path, current === void 0 ? NULL_PATH : path, normalize(renderEntry(previous)), normalize(renderEntry(current)), "before", "after", { context: 3 });
|
|
763
|
+
diffs.push(Object.freeze({
|
|
764
|
+
patch,
|
|
765
|
+
path,
|
|
766
|
+
status
|
|
767
|
+
}));
|
|
768
|
+
}
|
|
769
|
+
return Object.freeze(diffs);
|
|
770
|
+
}
|
|
771
|
+
async function captureDirectory(root, directory, label, entries) {
|
|
772
|
+
const children = await readdir(directory, { withFileTypes: true });
|
|
773
|
+
await Promise.all(children.map((child) => captureChild(root, join(directory, child.name), child, label, entries)));
|
|
774
|
+
}
|
|
775
|
+
async function captureChild(root, path, child, label, entries) {
|
|
776
|
+
const logicalPath = `${label}/${toPortablePath(relative(root, path))}`;
|
|
777
|
+
if (child.isDirectory()) {
|
|
778
|
+
entries.set(logicalPath, {
|
|
779
|
+
kind: "directory",
|
|
780
|
+
mode: await readMode(path)
|
|
781
|
+
});
|
|
782
|
+
await captureDirectory(root, path, label, entries);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (child.isFile()) {
|
|
786
|
+
entries.set(logicalPath, await readFileEntry(path));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (child.isSymbolicLink()) {
|
|
790
|
+
entries.set(logicalPath, {
|
|
791
|
+
kind: "symlink",
|
|
792
|
+
target: await readlink(path)
|
|
793
|
+
});
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
throw new Error(`Cannot snapshot unsupported fixture entry at ${logicalPath}.`);
|
|
797
|
+
}
|
|
798
|
+
async function readFileEntry(path) {
|
|
799
|
+
const [content, mode] = await Promise.all([readFile(path), readMode(path)]);
|
|
800
|
+
const text = decodeText(content);
|
|
801
|
+
if (text === void 0) return {
|
|
802
|
+
byteLength: content.byteLength,
|
|
803
|
+
digest: createHash("sha256").update(content).digest("hex"),
|
|
804
|
+
kind: "binary",
|
|
805
|
+
mode
|
|
806
|
+
};
|
|
807
|
+
return {
|
|
808
|
+
kind: "file",
|
|
809
|
+
mode,
|
|
810
|
+
text
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/** Bytes that are not UTF-8 text, and text holding NUL, cannot be rendered into a readable patch. */
|
|
814
|
+
function decodeText(content) {
|
|
815
|
+
if (content.includes(0)) return;
|
|
816
|
+
try {
|
|
817
|
+
return UTF8.decode(content);
|
|
818
|
+
} catch {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
async function readMode(path) {
|
|
823
|
+
return (await lstat(path)).mode & 511;
|
|
824
|
+
}
|
|
825
|
+
function equalEntry(left, right) {
|
|
826
|
+
if (left === void 0 || right === void 0) return left === right;
|
|
827
|
+
return renderEntry(left) === renderEntry(right);
|
|
828
|
+
}
|
|
829
|
+
function changeStatus(before, after) {
|
|
830
|
+
if (before === void 0) return "added";
|
|
831
|
+
return after === void 0 ? "removed" : "modified";
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Renders one entry as the text its patch diffs.
|
|
835
|
+
*
|
|
836
|
+
* Permission bits lead every entry that has them, which is what makes a `chmod` with no other
|
|
837
|
+
* change show up as a diff at all.
|
|
838
|
+
*/
|
|
839
|
+
function renderEntry(entry) {
|
|
840
|
+
if (entry === void 0) return "";
|
|
841
|
+
if (entry.kind === "symlink") return `symlink -> ${entry.target}\n`;
|
|
842
|
+
const header = `mode ${entry.mode.toString(8).padStart(3, "0")}\n`;
|
|
843
|
+
if (entry.kind === "directory") return `${header}directory\n`;
|
|
844
|
+
if (entry.kind === "binary") return `${header}binary ${String(entry.byteLength)} bytes sha256:${entry.digest}\n`;
|
|
845
|
+
return `${header}${entry.text}`;
|
|
846
|
+
}
|
|
847
|
+
function toPortablePath(path) {
|
|
848
|
+
return sep === "/" ? path : path.split(sep).join("/");
|
|
849
|
+
}
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/guards.ts
|
|
852
|
+
/** Narrows to a plain JSON-shaped object, excluding arrays and null. */
|
|
853
|
+
function isRecord(value) {
|
|
854
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
855
|
+
}
|
|
856
|
+
/** Freezes a JSON-shaped value all the way down. */
|
|
857
|
+
function deepFreeze(value) {
|
|
858
|
+
if (!isRecord(value) && !Array.isArray(value)) return value;
|
|
859
|
+
for (const property of Object.values(value)) deepFreeze(property);
|
|
860
|
+
return Object.freeze(value);
|
|
861
|
+
}
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/report.ts
|
|
864
|
+
/** Reads and validates the complete `check --json` document without rebuilding it. */
|
|
865
|
+
function parseReport(stdout, fail) {
|
|
866
|
+
let document;
|
|
867
|
+
try {
|
|
868
|
+
document = JSON.parse(stdout);
|
|
869
|
+
} catch {
|
|
870
|
+
throw fail("Check runner expected one JSON report on stdout.");
|
|
871
|
+
}
|
|
872
|
+
const problems = [];
|
|
873
|
+
if (!isCheckReport(document, problems)) throw fail(["Check runner received a JSON report it cannot read:", ...problems.map(indent)].join("\n"));
|
|
874
|
+
return deepFreeze(document);
|
|
875
|
+
}
|
|
876
|
+
function indent(problem) {
|
|
877
|
+
return ` - ${problem}`;
|
|
878
|
+
}
|
|
879
|
+
function isCheckReport(value, problems) {
|
|
880
|
+
return shape(REPORT)(value, "report", problems);
|
|
881
|
+
}
|
|
882
|
+
function reject(path, expectation, problems) {
|
|
883
|
+
problems.push(`${path}: expected ${expectation}`);
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
/** Validates the declared keys and ignores every other one, which is what lets the report grow. */
|
|
887
|
+
function shape(fields) {
|
|
888
|
+
return (value, path, problems) => {
|
|
889
|
+
if (!isRecord(value)) return reject(path, "an object", problems);
|
|
890
|
+
let valid = true;
|
|
891
|
+
for (const [key, check] of Object.entries(fields)) valid = check(value[key], `${path}.${key}`, problems) && valid;
|
|
892
|
+
return valid;
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
function arrayOf(check) {
|
|
896
|
+
return (value, path, problems) => {
|
|
897
|
+
if (!Array.isArray(value)) return reject(path, "an array", problems);
|
|
898
|
+
let valid = true;
|
|
899
|
+
for (const [index, element] of value.entries()) valid = check(element, `${path}[${String(index)}]`, problems) && valid;
|
|
900
|
+
return valid;
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function recordOf(check) {
|
|
904
|
+
return (value, path, problems) => {
|
|
905
|
+
if (!isRecord(value)) return reject(path, "an object", problems);
|
|
906
|
+
let valid = true;
|
|
907
|
+
for (const [key, entry] of Object.entries(value)) valid = check(entry, `${path}.${key}`, problems) && valid;
|
|
908
|
+
return valid;
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
function optional(check) {
|
|
912
|
+
return (value, path, problems) => value === void 0 || check(value, path, problems);
|
|
913
|
+
}
|
|
914
|
+
function oneOf(...allowed) {
|
|
915
|
+
const expectation = `one of ${allowed.map((option) => JSON.stringify(option)).join(", ")}`;
|
|
916
|
+
return (value, path, problems) => allowed.some((option) => option === value) || reject(path, expectation, problems);
|
|
917
|
+
}
|
|
918
|
+
const text = (value, path, problems) => typeof value === "string" || reject(path, "a string", problems);
|
|
919
|
+
const boolean = (value, path, problems) => typeof value === "boolean" || reject(path, "a boolean", problems);
|
|
920
|
+
const counter = (value, path, problems) => typeof value === "number" && Number.isInteger(value) && value >= 0 || reject(path, "a count", problems);
|
|
921
|
+
const position = (value, path, problems) => typeof value === "number" && Number.isInteger(value) && value > 0 || reject(path, "a 1-based position", problems);
|
|
922
|
+
const jsonObject = (value, path, problems) => isJsonObject(value) || reject(path, "a JSON object", problems);
|
|
923
|
+
const location = shape({
|
|
924
|
+
column: optional(position),
|
|
925
|
+
line: optional(position),
|
|
926
|
+
path: text
|
|
927
|
+
});
|
|
928
|
+
const findingFields = shape({
|
|
929
|
+
checkId: text,
|
|
930
|
+
details: optional(text),
|
|
931
|
+
findingId: text,
|
|
932
|
+
fixability: oneOf("auto", "guided", "manual"),
|
|
933
|
+
locations: optional(arrayOf(location)),
|
|
934
|
+
message: text,
|
|
935
|
+
metadata: optional(jsonObject),
|
|
936
|
+
presentation: optional(shape({
|
|
937
|
+
columns: arrayOf(shape({
|
|
938
|
+
align: optional(oneOf("left", "right")),
|
|
939
|
+
falseLabel: optional(text),
|
|
940
|
+
format: optional(oneOf("boolean", "integer", "percentage", "text")),
|
|
941
|
+
heading: text,
|
|
942
|
+
key: text,
|
|
943
|
+
trueLabel: optional(text)
|
|
944
|
+
})),
|
|
945
|
+
kind: oneOf("metadata-table"),
|
|
946
|
+
rowsKey: text
|
|
947
|
+
})),
|
|
948
|
+
scope: oneOf("global", "project"),
|
|
949
|
+
severity: oneOf("error", "info", "warn")
|
|
950
|
+
});
|
|
951
|
+
/**
|
|
952
|
+
* A finding, plus the one thing about a metadata table its own shape cannot say.
|
|
953
|
+
*
|
|
954
|
+
* `rowsKey` and every column `key` name something in `metadata`, and a name that matches nothing
|
|
955
|
+
* fails quietly where it is meant to be read: the table vanishes from human output, or a column
|
|
956
|
+
* renders blank down its whole length, while the JSON stays perfectly well-formed. A column that
|
|
957
|
+
* only some rows carry is ordinary — the check that reports one file per row need not give every
|
|
958
|
+
* file every property — so what is demanded here is that each row answer to at least one column.
|
|
959
|
+
*/
|
|
960
|
+
const finding = (value, path, problems) => {
|
|
961
|
+
const valid = findingFields(value, path, problems);
|
|
962
|
+
return isRecord(value) ? tableRowsAreReadable(value, path, problems) && valid : valid;
|
|
963
|
+
};
|
|
964
|
+
function tableRowsAreReadable(value, path, problems) {
|
|
965
|
+
const presentation = value["presentation"];
|
|
966
|
+
if (!isRecord(presentation) || presentation["kind"] !== "metadata-table") return true;
|
|
967
|
+
const rowsKey = presentation["rowsKey"];
|
|
968
|
+
const metadata = value["metadata"];
|
|
969
|
+
const rows = typeof rowsKey === "string" && isRecord(metadata) ? metadata[rowsKey] : void 0;
|
|
970
|
+
if (!Array.isArray(rows) || !rows.every(isRecord)) return reject(`${path}.metadata.${typeof rowsKey === "string" ? rowsKey : "?"}`, "an array of objects, because presentation.rowsKey names it", problems);
|
|
971
|
+
const keys = columnKeys(presentation["columns"]);
|
|
972
|
+
if (keys.length === 0) return true;
|
|
973
|
+
return rows.every((row, index) => keys.some((key) => row[key] !== void 0) ? true : reject(`${path}.metadata.${String(rowsKey)}[${String(index)}]`, `a row carrying at least one column key (${keys.join(", ")})`, problems));
|
|
974
|
+
}
|
|
975
|
+
function columnKeys(columns) {
|
|
976
|
+
if (!Array.isArray(columns)) return [];
|
|
977
|
+
return columns.flatMap((column) => {
|
|
978
|
+
const key = isRecord(column) ? column["key"] : void 0;
|
|
979
|
+
return typeof key === "string" ? [key] : [];
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
const REPORT = {
|
|
983
|
+
apps: arrayOf(shape({
|
|
984
|
+
appId: text,
|
|
985
|
+
detection: shape({
|
|
986
|
+
authenticated: optional(boolean),
|
|
987
|
+
installed: boolean,
|
|
988
|
+
version: optional(text)
|
|
989
|
+
}),
|
|
990
|
+
displayName: text,
|
|
991
|
+
support: optional(shape({
|
|
992
|
+
status: oneOf("supported", "unknown", "unsupported"),
|
|
993
|
+
supportedRange: text,
|
|
994
|
+
version: optional(text)
|
|
995
|
+
}))
|
|
996
|
+
})),
|
|
997
|
+
diagnostics: arrayOf(shape({
|
|
998
|
+
detail: optional(text),
|
|
999
|
+
id: text,
|
|
1000
|
+
message: text,
|
|
1001
|
+
path: optional(text),
|
|
1002
|
+
phase: oneOf("check", "detect", "files", "fix", "parse", "read", "support")
|
|
1003
|
+
})),
|
|
1004
|
+
findings: arrayOf(finding),
|
|
1005
|
+
fixes: optional(arrayOf(shape({
|
|
1006
|
+
checkId: text,
|
|
1007
|
+
findingId: text,
|
|
1008
|
+
manualSteps: arrayOf(text),
|
|
1009
|
+
message: optional(text),
|
|
1010
|
+
operations: arrayOf(shape({
|
|
1011
|
+
conflict: optional(text),
|
|
1012
|
+
diff: optional(text),
|
|
1013
|
+
effect: oneOf("archive", "conflict", "create", "move", "noop", "remove", "symlink", "update"),
|
|
1014
|
+
paths: arrayOf(text)
|
|
1015
|
+
})),
|
|
1016
|
+
status: oneOf("applied", "failed", "partial", "planned"),
|
|
1017
|
+
summary: text
|
|
1018
|
+
}))),
|
|
1019
|
+
kind: oneOf("check-report"),
|
|
1020
|
+
passedChecks: arrayOf(shape({
|
|
1021
|
+
id: text,
|
|
1022
|
+
title: text
|
|
1023
|
+
})),
|
|
1024
|
+
schemaVersion: oneOf(1),
|
|
1025
|
+
status: oneOf("clean", "empty", "error", "operational-error", "warning"),
|
|
1026
|
+
summary: shape({
|
|
1027
|
+
categories: recordOf(shape({
|
|
1028
|
+
errors: counter,
|
|
1029
|
+
informational: counter,
|
|
1030
|
+
passed: counter,
|
|
1031
|
+
warnings: counter
|
|
1032
|
+
})),
|
|
1033
|
+
diagnostics: counter,
|
|
1034
|
+
errors: counter,
|
|
1035
|
+
exitCode: oneOf(0, 1, 2, 3),
|
|
1036
|
+
informational: counter,
|
|
1037
|
+
passed: counter,
|
|
1038
|
+
warnings: counter
|
|
1039
|
+
})
|
|
1040
|
+
};
|
|
1041
|
+
function isJsonObject(value) {
|
|
1042
|
+
return isRecord(value) && Object.values(value).every(isJsonValue);
|
|
1043
|
+
}
|
|
1044
|
+
function isJsonValue(value) {
|
|
1045
|
+
return value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string" || Array.isArray(value) && value.every(isJsonValue) || isJsonObject(value);
|
|
1046
|
+
}
|
|
1047
|
+
//#endregion
|
|
1048
|
+
//#region src/run-result.ts
|
|
1049
|
+
/** Converts either CLI execution boundary into the testkit's stable result contract. */
|
|
1050
|
+
async function collectCheckResult(seed, execute) {
|
|
1051
|
+
const before = await captureFilesystem(seed);
|
|
1052
|
+
const capture = await execute();
|
|
1053
|
+
const normalize = createNormalizer(seed);
|
|
1054
|
+
const spawned = capture.boundary === "spawned";
|
|
1055
|
+
const transcript = {
|
|
1056
|
+
exitCode: capture.exitCode,
|
|
1057
|
+
signal: spawned ? capture.signal : null,
|
|
1058
|
+
stderr: normalize(capture.stderr),
|
|
1059
|
+
stdout: normalize(capture.stdout),
|
|
1060
|
+
timedOutAfterMs: spawned ? capture.timedOutAfterMs : void 0
|
|
1061
|
+
};
|
|
1062
|
+
const exitCode = requireExitCode(transcript);
|
|
1063
|
+
if (capture.boundary === "in-process") requireAppliedExitCode(capture.appliedExitCode, exitCode, transcript);
|
|
1064
|
+
const report = parseReport(transcript.stdout, (message) => runError(message, transcript));
|
|
1065
|
+
if (report.summary.exitCode !== exitCode) throw runError("Check runner saw a report and process with disagreeing exit codes.", transcript);
|
|
1066
|
+
const after = await captureFilesystem(seed);
|
|
1067
|
+
return Object.freeze({
|
|
1068
|
+
diffs: diffFilesystem(before, after, normalize),
|
|
1069
|
+
exitCode,
|
|
1070
|
+
findings: report.findings,
|
|
1071
|
+
report,
|
|
1072
|
+
stderr: transcript.stderr,
|
|
1073
|
+
stdout: transcript.stdout
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Proves the in-process run applied the exit code it returned.
|
|
1078
|
+
*
|
|
1079
|
+
* Only this boundary can disagree with itself: a child process has one exit status and no second
|
|
1080
|
+
* opinion to compare it against.
|
|
1081
|
+
*/
|
|
1082
|
+
function requireAppliedExitCode(appliedExitCode, exitCode, transcript) {
|
|
1083
|
+
if (appliedExitCode === void 0) throw runError("Check runner never reported the exit code it applied.", transcript);
|
|
1084
|
+
if (appliedExitCode !== exitCode) throw runError("Check runner saw two disagreeing exit codes for one run.", transcript);
|
|
1085
|
+
}
|
|
1086
|
+
function requireExitCode(transcript) {
|
|
1087
|
+
if (transcript.timedOutAfterMs !== void 0) throw runError(`Check runner killed the run after ${String(transcript.timedOutAfterMs)}ms without an exit.`, transcript);
|
|
1088
|
+
if (transcript.signal !== null) throw runError(`Check runner terminated from signal ${transcript.signal}.`, transcript);
|
|
1089
|
+
if (transcript.exitCode === null) throw runError("Check runner terminated without an exit code.", transcript);
|
|
1090
|
+
if (transcript.exitCode !== 0 && transcript.exitCode !== 1 && transcript.exitCode !== 2 && transcript.exitCode !== 3) throw runError(`Check runner returned unsupported exit code ${String(transcript.exitCode)}.`, transcript);
|
|
1091
|
+
return transcript.exitCode;
|
|
1092
|
+
}
|
|
1093
|
+
const TRANSCRIPT_LIMIT = 2e3;
|
|
1094
|
+
function runError(message, transcript) {
|
|
1095
|
+
return new Error([
|
|
1096
|
+
message,
|
|
1097
|
+
` exit code: ${describeExit(transcript)}`,
|
|
1098
|
+
` stdout: ${describeStream(transcript.stdout)}`,
|
|
1099
|
+
` stderr: ${describeStream(transcript.stderr)}`
|
|
1100
|
+
].join("\n"));
|
|
1101
|
+
}
|
|
1102
|
+
function describeExit(transcript) {
|
|
1103
|
+
if (transcript.signal !== null) return `signal ${transcript.signal}`;
|
|
1104
|
+
return transcript.exitCode === null ? "<missing>" : String(transcript.exitCode);
|
|
1105
|
+
}
|
|
1106
|
+
function describeStream(value) {
|
|
1107
|
+
if (value === "") return "<empty>";
|
|
1108
|
+
const trimmed = value.length > TRANSCRIPT_LIMIT ? value.slice(0, TRANSCRIPT_LIMIT) : value;
|
|
1109
|
+
const suffix = value.length > TRANSCRIPT_LIMIT ? "… (truncated)" : "";
|
|
1110
|
+
return `${trimmed.replaceAll("\n", "\n ")}${suffix}`;
|
|
1111
|
+
}
|
|
1112
|
+
/** Replaces a seed's machine-specific paths with stable labels, longest path first. */
|
|
1113
|
+
function createNormalizer(seed) {
|
|
1114
|
+
const ordered = [...[
|
|
1115
|
+
[seed.homeDir, "<HOME>"],
|
|
1116
|
+
[seed.pathDir, "<PATH>"],
|
|
1117
|
+
[seed.workspaceDir, "<WORKSPACE>"],
|
|
1118
|
+
[dirname(seed.homeDir), "<SEED>"]
|
|
1119
|
+
]].sort((left, right) => right[0].length - left[0].length);
|
|
1120
|
+
return (value) => {
|
|
1121
|
+
let normalized = value;
|
|
1122
|
+
for (const [path, label] of ordered) normalized = normalized.replaceAll(path, label);
|
|
1123
|
+
return normalized;
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
//#endregion
|
|
1127
|
+
//#region src/binary-runner.ts
|
|
1128
|
+
/**
|
|
1129
|
+
* How long a compiled run may take before the runner stops waiting.
|
|
1130
|
+
*
|
|
1131
|
+
* Generous on purpose: this is the bound that turns a hang into a readable failure, not a budget
|
|
1132
|
+
* anyone should be tuning a passing test against.
|
|
1133
|
+
*/
|
|
1134
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
1135
|
+
/** Flags the runner supplies itself, which a caller repeating would silently duplicate. */
|
|
1136
|
+
const RESERVED_FLAGS = /* @__PURE__ */ new Set([
|
|
1137
|
+
"--home",
|
|
1138
|
+
"--json",
|
|
1139
|
+
"--path"
|
|
1140
|
+
]);
|
|
1141
|
+
/** Runs a compiled Aura distribution's `check --json` command against one deterministic seed. */
|
|
1142
|
+
async function runBinaryCheck(options) {
|
|
1143
|
+
if (!isAbsolute(options.binaryPath)) throw new Error(`Compiled binary path must be absolute. Received: ${options.binaryPath}`);
|
|
1144
|
+
rejectReservedFlags(options.args ?? []);
|
|
1145
|
+
return collectCheckResult(options.seed, () => executeBinary(options));
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Refuses an argument the runner already passes.
|
|
1149
|
+
*
|
|
1150
|
+
* Repeating one is not an error the CLI reports — it parses as a second occurrence and quietly wins
|
|
1151
|
+
* or loses depending on the flag — so the test that did it would fail somewhere else entirely.
|
|
1152
|
+
*/
|
|
1153
|
+
function rejectReservedFlags(args) {
|
|
1154
|
+
for (const arg of args) {
|
|
1155
|
+
const [flag] = arg.split("=");
|
|
1156
|
+
if (flag !== void 0 && RESERVED_FLAGS.has(flag)) throw new Error(`Check runner already supplies ${flag}. Remove it from args; the seed decides its value.`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
function executeBinary(options) {
|
|
1160
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1161
|
+
return new Promise((resolve, reject) => {
|
|
1162
|
+
try {
|
|
1163
|
+
const child = spawn(options.binaryPath, [
|
|
1164
|
+
"check",
|
|
1165
|
+
"--json",
|
|
1166
|
+
"--home",
|
|
1167
|
+
options.seed.homeDir,
|
|
1168
|
+
"--path",
|
|
1169
|
+
options.seed.pathDir,
|
|
1170
|
+
...options.args ?? []
|
|
1171
|
+
], {
|
|
1172
|
+
cwd: options.seed.workspaceDir,
|
|
1173
|
+
env: {
|
|
1174
|
+
HOME: options.seed.homeDir,
|
|
1175
|
+
NO_COLOR: "1",
|
|
1176
|
+
PATH: options.seed.pathDir
|
|
1177
|
+
},
|
|
1178
|
+
stdio: [
|
|
1179
|
+
"ignore",
|
|
1180
|
+
"pipe",
|
|
1181
|
+
"pipe"
|
|
1182
|
+
]
|
|
1183
|
+
});
|
|
1184
|
+
const stderr = [];
|
|
1185
|
+
const stdout = [];
|
|
1186
|
+
let timedOut = false;
|
|
1187
|
+
const timer = setTimeout(() => {
|
|
1188
|
+
timedOut = true;
|
|
1189
|
+
child.kill("SIGKILL");
|
|
1190
|
+
}, timeoutMs);
|
|
1191
|
+
child.stderr.setEncoding("utf8");
|
|
1192
|
+
child.stdout.setEncoding("utf8");
|
|
1193
|
+
child.stderr.on("data", (chunk) => {
|
|
1194
|
+
stderr.push(chunk);
|
|
1195
|
+
});
|
|
1196
|
+
child.stdout.on("data", (chunk) => {
|
|
1197
|
+
stdout.push(chunk);
|
|
1198
|
+
});
|
|
1199
|
+
child.once("error", (error) => {
|
|
1200
|
+
clearTimeout(timer);
|
|
1201
|
+
reject(new Error(`Could not launch compiled Aura binary: ${error.message}`, { cause: error }));
|
|
1202
|
+
});
|
|
1203
|
+
child.once("close", (exitCode, signal) => {
|
|
1204
|
+
clearTimeout(timer);
|
|
1205
|
+
resolve({
|
|
1206
|
+
boundary: "spawned",
|
|
1207
|
+
exitCode,
|
|
1208
|
+
signal,
|
|
1209
|
+
stderr: stderr.join(""),
|
|
1210
|
+
stdout: stdout.join(""),
|
|
1211
|
+
...timedOut ? { timedOutAfterMs: timeoutMs } : {}
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1216
|
+
reject(new Error(`Could not launch compiled Aura binary: ${message}`, { cause: error }));
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region ../core/src/http.ts
|
|
1222
|
+
/**
|
|
1223
|
+
* Hosts allowed to serve plain `http:`.
|
|
1224
|
+
*
|
|
1225
|
+
* Literal loopback addresses only: `localhost` can resolve anywhere the resolver decides, so a
|
|
1226
|
+
* credential sent to it could still leave the machine.
|
|
1227
|
+
*/
|
|
1228
|
+
const LOOPBACK_HOSTS$1 = ["127.0.0.1", "[::1]"];
|
|
1229
|
+
/**
|
|
1230
|
+
* Creates {@link Environment.httpGet}: a bounded, TLS-only GET that never rejects.
|
|
1231
|
+
*
|
|
1232
|
+
* Redirects are refused (`redirect: "error"`) so a directory cannot bounce a bearer token to an
|
|
1233
|
+
* address the caller never named. The response body is streamed against the byte cap rather than
|
|
1234
|
+
* buffered first, so an oversized body is abandoned mid-transfer.
|
|
1235
|
+
*/
|
|
1236
|
+
function createHttpGet() {
|
|
1237
|
+
return async (request) => {
|
|
1238
|
+
const vetted = vetHttpUrl(request.url);
|
|
1239
|
+
if (!(vetted instanceof URL)) return {
|
|
1240
|
+
kind: "failure",
|
|
1241
|
+
reason: vetted
|
|
1242
|
+
};
|
|
1243
|
+
const maxBytes = clamp(request.maxResponseBytes, MAX_HTTP_RESPONSE_BYTES, MAX_HTTP_RESPONSE_BYTES);
|
|
1244
|
+
try {
|
|
1245
|
+
return await readBody(await fetch(vetted, {
|
|
1246
|
+
headers: request.headers ?? {},
|
|
1247
|
+
method: "GET",
|
|
1248
|
+
redirect: "error",
|
|
1249
|
+
signal: AbortSignal.timeout(clampHttpTimeout(request.timeoutMs))
|
|
1250
|
+
}), maxBytes, request.responseType === "bytes");
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
return {
|
|
1253
|
+
kind: "failure",
|
|
1254
|
+
reason: failureReason(error)
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
/** Parses a URL and applies the TLS-only rule, collapsing both refusals into failure reasons. */
|
|
1260
|
+
function vetHttpUrl(raw) {
|
|
1261
|
+
let url;
|
|
1262
|
+
try {
|
|
1263
|
+
url = new URL(raw);
|
|
1264
|
+
} catch {
|
|
1265
|
+
return "invalid-url";
|
|
1266
|
+
}
|
|
1267
|
+
return isAllowedHttpUrl(url) ? url : "insecure-url";
|
|
1268
|
+
}
|
|
1269
|
+
/** Applies the shared timeout default and ceiling every HTTP client in the kernel honors. */
|
|
1270
|
+
function clampHttpTimeout(value) {
|
|
1271
|
+
return clamp(value, DEFAULT_HTTP_TIMEOUT_MS, MAX_HTTP_TIMEOUT_MS);
|
|
1272
|
+
}
|
|
1273
|
+
/** Replaces an absent, zero, negative, or non-finite value by the default; caps at the ceiling. */
|
|
1274
|
+
function clamp(value, fallback, ceiling) {
|
|
1275
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return fallback;
|
|
1276
|
+
return Math.min(value, ceiling);
|
|
1277
|
+
}
|
|
1278
|
+
function isLoopbackHttp(url) {
|
|
1279
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS$1.includes(url.hostname);
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Whether a parsed URL satisfies the TLS rule {@link createHttpGet} enforces.
|
|
1283
|
+
*
|
|
1284
|
+
* Exposed so configuration that carries a URL — a registered skill directory, a preset entry — can
|
|
1285
|
+
* be refused at validation time with a named problem instead of failing later mid-request.
|
|
1286
|
+
*/
|
|
1287
|
+
function isAllowedHttpUrl(url) {
|
|
1288
|
+
return url.protocol === "https:" || isLoopbackHttp(url);
|
|
1289
|
+
}
|
|
1290
|
+
/** Streams the body against the byte cap; decoding happens only once the cap is known to hold. */
|
|
1291
|
+
async function readBody(response, maxBytes, binary) {
|
|
1292
|
+
const reader = response.body?.getReader();
|
|
1293
|
+
if (reader === void 0) return binary ? {
|
|
1294
|
+
body: /* @__PURE__ */ new Uint8Array(),
|
|
1295
|
+
kind: "binary-response",
|
|
1296
|
+
status: response.status
|
|
1297
|
+
} : {
|
|
1298
|
+
body: "",
|
|
1299
|
+
kind: "response",
|
|
1300
|
+
status: response.status
|
|
1301
|
+
};
|
|
1302
|
+
const chunks = [];
|
|
1303
|
+
let received = 0;
|
|
1304
|
+
for (;;) {
|
|
1305
|
+
const { done, value } = await reader.read();
|
|
1306
|
+
if (done) break;
|
|
1307
|
+
received += value.byteLength;
|
|
1308
|
+
if (received > maxBytes) {
|
|
1309
|
+
await reader.cancel();
|
|
1310
|
+
return {
|
|
1311
|
+
kind: "failure",
|
|
1312
|
+
reason: "response-too-large"
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
chunks.push(value);
|
|
1316
|
+
}
|
|
1317
|
+
const body = concatenate(chunks, received);
|
|
1318
|
+
return binary ? {
|
|
1319
|
+
body,
|
|
1320
|
+
kind: "binary-response",
|
|
1321
|
+
status: response.status
|
|
1322
|
+
} : {
|
|
1323
|
+
body: new TextDecoder().decode(body),
|
|
1324
|
+
kind: "response",
|
|
1325
|
+
status: response.status
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
function concatenate(chunks, length) {
|
|
1329
|
+
const joined = new Uint8Array(length);
|
|
1330
|
+
let offset = 0;
|
|
1331
|
+
for (const chunk of chunks) {
|
|
1332
|
+
joined.set(chunk, offset);
|
|
1333
|
+
offset += chunk.byteLength;
|
|
1334
|
+
}
|
|
1335
|
+
return joined;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Collapses every throw into the closed failure vocabulary.
|
|
1339
|
+
*
|
|
1340
|
+
* Error text is deliberately dropped: runtime messages can echo the request, and the request may
|
|
1341
|
+
* carry credentials in its headers.
|
|
1342
|
+
*/
|
|
1343
|
+
function failureReason(error) {
|
|
1344
|
+
const name = error instanceof Error ? error.name : "";
|
|
1345
|
+
return name === "TimeoutError" || name === "AbortError" ? "timeout" : "network";
|
|
1346
|
+
}
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region src/http.ts
|
|
1349
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "[::1]"]);
|
|
1350
|
+
const realHttpGet = createHttpGet();
|
|
1351
|
+
/**
|
|
1352
|
+
* The network policy every testkit runner injects: loopback servers only.
|
|
1353
|
+
*
|
|
1354
|
+
* A test run is hermetic by construction — a distribution that registers a real skill directory
|
|
1355
|
+
* must not make its integration tests reach that host. Mock servers listen on `127.0.0.1`, which
|
|
1356
|
+
* this passes straight through to the kernel's own client, caps and all.
|
|
1357
|
+
*/
|
|
1358
|
+
const loopbackOnlyHttpGet = (request) => {
|
|
1359
|
+
let url;
|
|
1360
|
+
try {
|
|
1361
|
+
url = new URL(request.url);
|
|
1362
|
+
} catch {
|
|
1363
|
+
return Promise.resolve({
|
|
1364
|
+
kind: "failure",
|
|
1365
|
+
reason: "invalid-url"
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
if (!LOOPBACK_HOSTS.has(url.hostname)) return Promise.resolve({
|
|
1369
|
+
kind: "failure",
|
|
1370
|
+
reason: "network"
|
|
1371
|
+
});
|
|
1372
|
+
return realHttpGet(request);
|
|
1373
|
+
};
|
|
1374
|
+
//#endregion
|
|
1375
|
+
//#region src/text-capture.ts
|
|
1376
|
+
/** A writable stream that accumulates everything written to it as text. */
|
|
1377
|
+
function createTextCapture() {
|
|
1378
|
+
const chunks = [];
|
|
1379
|
+
const stream = new PassThrough();
|
|
1380
|
+
stream.setEncoding("utf8");
|
|
1381
|
+
stream.on("data", (chunk) => {
|
|
1382
|
+
chunks.push(chunk);
|
|
1383
|
+
});
|
|
1384
|
+
return {
|
|
1385
|
+
read: () => chunks.join(""),
|
|
1386
|
+
stream
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
//#endregion
|
|
1390
|
+
//#region src/runner.ts
|
|
1391
|
+
/** Runs `check --json` without reading process state and returns snapshot-ready output. */
|
|
1392
|
+
async function runCheck(options) {
|
|
1393
|
+
return collectCheckResult(options.seed, async () => {
|
|
1394
|
+
const stderr = createTextCapture();
|
|
1395
|
+
const stdout = createTextCapture();
|
|
1396
|
+
let appliedExitCode;
|
|
1397
|
+
const exitCode = await runCli(options.distro, {
|
|
1398
|
+
argv: [
|
|
1399
|
+
"check",
|
|
1400
|
+
"--json",
|
|
1401
|
+
...options.args ?? []
|
|
1402
|
+
],
|
|
1403
|
+
colorDepth: 0,
|
|
1404
|
+
cwd: options.seed.workspaceDir,
|
|
1405
|
+
environmentVariables: { PATH: options.seed.pathDir },
|
|
1406
|
+
homeDir: options.seed.homeDir,
|
|
1407
|
+
httpGet: loopbackOnlyHttpGet,
|
|
1408
|
+
setExitCode: (code) => {
|
|
1409
|
+
appliedExitCode = code;
|
|
1410
|
+
},
|
|
1411
|
+
stderr: stderr.stream,
|
|
1412
|
+
stdin: Readable.from([]),
|
|
1413
|
+
stdout: stdout.stream
|
|
1414
|
+
});
|
|
1415
|
+
return {
|
|
1416
|
+
appliedExitCode,
|
|
1417
|
+
boundary: "in-process",
|
|
1418
|
+
exitCode,
|
|
1419
|
+
stderr: stderr.read(),
|
|
1420
|
+
stdout: stdout.read()
|
|
1421
|
+
};
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
//#endregion
|
|
1425
|
+
//#region src/setup-runner.ts
|
|
1426
|
+
/**
|
|
1427
|
+
* Runs `setup --yes` in-process against a seed and returns snapshot-ready output.
|
|
1428
|
+
*
|
|
1429
|
+
* `--yes` because this boundary has no terminal: every question resolves to the default the wizard
|
|
1430
|
+
* would have proposed, which is exactly what the acceptance tests need to prove about converge and
|
|
1431
|
+
* end-on-green behaviour. Interactive keypress flows are covered by the CLI's own engine tests.
|
|
1432
|
+
*/
|
|
1433
|
+
async function runSetup(options) {
|
|
1434
|
+
const before = await captureFilesystem(options.seed);
|
|
1435
|
+
const stderr = createTextCapture();
|
|
1436
|
+
const stdout = createTextCapture();
|
|
1437
|
+
let exitCode = -1;
|
|
1438
|
+
await runCli(options.distro, {
|
|
1439
|
+
argv: [
|
|
1440
|
+
"setup",
|
|
1441
|
+
"--yes",
|
|
1442
|
+
...options.args ?? []
|
|
1443
|
+
],
|
|
1444
|
+
colorDepth: 0,
|
|
1445
|
+
cwd: options.seed.workspaceDir,
|
|
1446
|
+
environmentVariables: {
|
|
1447
|
+
...options.environmentVariables,
|
|
1448
|
+
PATH: options.seed.pathDir
|
|
1449
|
+
},
|
|
1450
|
+
homeDir: options.seed.homeDir,
|
|
1451
|
+
httpGet: loopbackOnlyHttpGet,
|
|
1452
|
+
setExitCode: (code) => {
|
|
1453
|
+
exitCode = code;
|
|
1454
|
+
},
|
|
1455
|
+
stderr: stderr.stream,
|
|
1456
|
+
stdin: Readable.from([]),
|
|
1457
|
+
stdout: stdout.stream
|
|
1458
|
+
});
|
|
1459
|
+
const normalize = createNormalizer(options.seed);
|
|
1460
|
+
const after = await captureFilesystem(options.seed);
|
|
1461
|
+
return Object.freeze({
|
|
1462
|
+
diffs: diffFilesystem(before, after, normalize),
|
|
1463
|
+
exitCode,
|
|
1464
|
+
stderr: normalize(stderr.read()),
|
|
1465
|
+
stdout: normalize(stdout.read())
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
//#endregion
|
|
1469
|
+
//#region src/convergence.ts
|
|
1470
|
+
const JOURNAL_ENTRY = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z(?:-\d{4})?$/u;
|
|
1471
|
+
const CONVERGED_MARKERS = ["Already converged — nothing to do.", "Nothing to fix."];
|
|
1472
|
+
/**
|
|
1473
|
+
* Runs one converge boundary twice and proves the second pass had no work to plan or journal.
|
|
1474
|
+
*
|
|
1475
|
+
* Throws framework-independent errors so distributions can use this helper from any test runner.
|
|
1476
|
+
*/
|
|
1477
|
+
async function expectConvergedTwice(seed, run) {
|
|
1478
|
+
const first = await run();
|
|
1479
|
+
requireSuccessful("first", first);
|
|
1480
|
+
const afterFirst = await captureFilesystem(seed);
|
|
1481
|
+
const journalAfterFirst = await journalEntries(seed);
|
|
1482
|
+
const second = await run();
|
|
1483
|
+
requireSuccessful("second", second);
|
|
1484
|
+
const transcript = `${second.stdout}\n${second.stderr}`;
|
|
1485
|
+
if (!CONVERGED_MARKERS.some((marker) => transcript.includes(marker))) throw convergenceError("second run did not report that it was already converged", second);
|
|
1486
|
+
if (transcript.includes("Applied ")) throw convergenceError("second run reported applied operations", second);
|
|
1487
|
+
const reportedDiffs = second.diffs.filter((diff) => !isJournalPath(diff.path));
|
|
1488
|
+
if (reportedDiffs.length > 0) throw convergenceError(`second run reported ${String(reportedDiffs.length)} filesystem diff(s): ${reportedDiffs.map((diff) => diff.path).join(", ")}`, second);
|
|
1489
|
+
const snapshotDiffs = diffFilesystem(afterFirst, await captureFilesystem(seed), (value) => value).filter((diff) => !isJournalPath(diff.path));
|
|
1490
|
+
if (snapshotDiffs.length > 0) throw convergenceError(`second run changed ${String(snapshotDiffs.length)} captured path(s): ${snapshotDiffs.map((diff) => diff.path).join(", ")}`, second);
|
|
1491
|
+
if ((await journalEntries(seed)).join("\n") !== journalAfterFirst.join("\n")) throw convergenceError("second run changed the undo journal entries", second);
|
|
1492
|
+
return Object.freeze({
|
|
1493
|
+
first,
|
|
1494
|
+
second
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Whether a path belongs to the undo journal rather than to the state being converged.
|
|
1499
|
+
*
|
|
1500
|
+
* The journal is where a run records what it replaced, and it holds the target-lock directory a run
|
|
1501
|
+
* touches whether or not it writes anything. Convergence is a claim about the machine's
|
|
1502
|
+
* configuration, so journal paths are excluded here and asserted separately, by entry name.
|
|
1503
|
+
*/
|
|
1504
|
+
function isJournalPath(path) {
|
|
1505
|
+
return path.includes("/.backups/") || path.endsWith("/.backups");
|
|
1506
|
+
}
|
|
1507
|
+
function requireSuccessful(label, result) {
|
|
1508
|
+
if (result.exitCode !== 0) throw convergenceError(`${label} run exited with ${String(result.exitCode)}`, result);
|
|
1509
|
+
}
|
|
1510
|
+
async function journalEntries(seed) {
|
|
1511
|
+
try {
|
|
1512
|
+
return (await readdir(join(seed.homeDir, "agents", ".backups"))).filter((name) => JOURNAL_ENTRY.test(name)).sort();
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
if (errorCode(error) === "ENOENT") return [];
|
|
1515
|
+
throw error;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
function errorCode(error) {
|
|
1519
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return;
|
|
1520
|
+
const code = error.code;
|
|
1521
|
+
return typeof code === "string" ? code : void 0;
|
|
1522
|
+
}
|
|
1523
|
+
function convergenceError(message, result) {
|
|
1524
|
+
return new Error([
|
|
1525
|
+
`Expected converge-twice invariant: ${message}.`,
|
|
1526
|
+
` stdout: ${result.stdout === "" ? "<empty>" : result.stdout}`,
|
|
1527
|
+
` stderr: ${result.stderr === "" ? "<empty>" : result.stderr}`
|
|
1528
|
+
].join("\n"));
|
|
1529
|
+
}
|
|
1530
|
+
//#endregion
|
|
1531
|
+
//#region src/types.ts
|
|
1532
|
+
/**
|
|
1533
|
+
* Matches any single argument in that position.
|
|
1534
|
+
*
|
|
1535
|
+
* Arity still has to match, so a response stays an exact description of one shape of invocation —
|
|
1536
|
+
* this only frees the positions whose value a test cannot predict, such as a temporary path.
|
|
1537
|
+
*/
|
|
1538
|
+
const ANY_ARGUMENT = Symbol("aura-testkit.anyArgument");
|
|
1539
|
+
//#endregion
|
|
1540
|
+
//#region src/shims.ts
|
|
1541
|
+
const COMMAND_NAME = /^[A-Za-z0-9._+-]+$/u;
|
|
1542
|
+
const RESERVED_NAMES = /* @__PURE__ */ new Set([".", ".."]);
|
|
1543
|
+
async function writeShim(options) {
|
|
1544
|
+
const { command, logDir, pathDir, responses } = options;
|
|
1545
|
+
validateShim(command, responses);
|
|
1546
|
+
const path = join(pathDir, command);
|
|
1547
|
+
await writeFile(path, renderShim(command, join(logDir, command), responses), "utf8");
|
|
1548
|
+
await chmod(path, 493);
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Reads back every invocation one shim recorded.
|
|
1552
|
+
*
|
|
1553
|
+
* The log frames each record as its argument count followed by that many arguments, all
|
|
1554
|
+
* NUL-terminated. Arguments cannot contain NUL — {@link validateShim} rejects that — so the framing
|
|
1555
|
+
* stays unambiguous for empty arguments and for arguments holding newlines.
|
|
1556
|
+
*/
|
|
1557
|
+
async function readInvocations(logDir, command) {
|
|
1558
|
+
if (!isPortableCommandName(command)) return [];
|
|
1559
|
+
let log;
|
|
1560
|
+
try {
|
|
1561
|
+
log = await readFile(join(logDir, command), "utf8");
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
if (isNotFound(error)) return [];
|
|
1564
|
+
throw error;
|
|
1565
|
+
}
|
|
1566
|
+
const fields = log.split("\0");
|
|
1567
|
+
fields.pop();
|
|
1568
|
+
const invocations = [];
|
|
1569
|
+
let cursor = 0;
|
|
1570
|
+
while (cursor < fields.length) {
|
|
1571
|
+
const count = Number(fields[cursor]);
|
|
1572
|
+
if (!Number.isInteger(count) || count < 0 || cursor + 1 + count > fields.length) throw new Error(`Shim ${command} wrote an invocation log this runner cannot read.`);
|
|
1573
|
+
invocations.push(Object.freeze(fields.slice(cursor + 1, cursor + 1 + count)));
|
|
1574
|
+
cursor += 1 + count;
|
|
1575
|
+
}
|
|
1576
|
+
return Object.freeze(invocations);
|
|
1577
|
+
}
|
|
1578
|
+
function validateShim(command, responses) {
|
|
1579
|
+
if (!isPortableCommandName(command)) throw new Error(`Shim command must be a portable executable name. Received: ${command}`);
|
|
1580
|
+
if (responses.length === 0) throw new Error(`Shim ${command} must declare at least one response.`);
|
|
1581
|
+
const invocations = /* @__PURE__ */ new Set();
|
|
1582
|
+
for (const response of responses) {
|
|
1583
|
+
const key = describeArgs(response.args);
|
|
1584
|
+
if (invocations.has(key)) throw new Error(`Shim ${command} declares the same arguments more than once: ${key}`);
|
|
1585
|
+
invocations.add(key);
|
|
1586
|
+
validateResponse(command, response);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
/** The name has to be a single path segment: `.` and `..` pass the character test but are not. */
|
|
1590
|
+
function isPortableCommandName(command) {
|
|
1591
|
+
return COMMAND_NAME.test(command) && !RESERVED_NAMES.has(command);
|
|
1592
|
+
}
|
|
1593
|
+
function describeArgs(args) {
|
|
1594
|
+
return JSON.stringify(args.map((argument) => isAnyArgument(argument) ? null : argument));
|
|
1595
|
+
}
|
|
1596
|
+
function isAnyArgument(argument) {
|
|
1597
|
+
return argument === ANY_ARGUMENT;
|
|
1598
|
+
}
|
|
1599
|
+
function validateResponse(command, response) {
|
|
1600
|
+
const exitCode = response.exitCode ?? 0;
|
|
1601
|
+
if (!Number.isInteger(exitCode) || exitCode < 0 || exitCode > 255) throw new Error(`Shim ${command} exit code must be an integer from 0 to 255.`);
|
|
1602
|
+
const literals = response.args.filter((argument) => !isAnyArgument(argument));
|
|
1603
|
+
for (const value of [
|
|
1604
|
+
command,
|
|
1605
|
+
...literals,
|
|
1606
|
+
response.stdout ?? "",
|
|
1607
|
+
response.stderr ?? ""
|
|
1608
|
+
]) if (value.includes("\0")) throw new Error(`Shim ${command} values cannot contain a NUL character.`);
|
|
1609
|
+
}
|
|
1610
|
+
function renderShim(command, logPath, responses) {
|
|
1611
|
+
return [
|
|
1612
|
+
"#!/bin/sh",
|
|
1613
|
+
"{",
|
|
1614
|
+
` printf '%s\\0' "$#"`,
|
|
1615
|
+
" for aura_recorded in \"$@\"; do",
|
|
1616
|
+
` printf '%s\\0' "$aura_recorded"`,
|
|
1617
|
+
" done",
|
|
1618
|
+
`} >> ${shellQuote(logPath)}`,
|
|
1619
|
+
...responses.map(renderResponse),
|
|
1620
|
+
`printf '%s' ${shellQuote(`aura-testkit: unmatched invocation: ${command}`)} >&2`,
|
|
1621
|
+
"for aura_reported in \"$@\"; do",
|
|
1622
|
+
" printf \" %s\" \"$aura_reported\" >&2",
|
|
1623
|
+
"done",
|
|
1624
|
+
"printf '\\n' >&2",
|
|
1625
|
+
"exit 2",
|
|
1626
|
+
""
|
|
1627
|
+
].join("\n");
|
|
1628
|
+
}
|
|
1629
|
+
function renderResponse(response) {
|
|
1630
|
+
return [
|
|
1631
|
+
`if ${[`[ "$#" -eq ${String(response.args.length)} ]`, ...response.args.flatMap((argument, index) => isAnyArgument(argument) ? [] : [`[ "$${String(index + 1)}" = ${shellQuote(argument)} ]`])].join(" && ")}; then`,
|
|
1632
|
+
...response.stdout === void 0 ? [] : [` printf '%s' ${shellQuote(response.stdout)}`],
|
|
1633
|
+
...response.stderr === void 0 ? [] : [` printf '%s' ${shellQuote(response.stderr)} >&2`],
|
|
1634
|
+
` exit ${String(response.exitCode ?? 0)}`,
|
|
1635
|
+
"fi"
|
|
1636
|
+
].join("\n");
|
|
1637
|
+
}
|
|
1638
|
+
function shellQuote(value) {
|
|
1639
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
1640
|
+
}
|
|
1641
|
+
function isNotFound(error) {
|
|
1642
|
+
return isRecord(error) && error["code"] === "ENOENT";
|
|
1643
|
+
}
|
|
1644
|
+
//#endregion
|
|
1645
|
+
//#region src/seed.ts
|
|
1646
|
+
var SeedBuilder = class {
|
|
1647
|
+
#homeFiles = /* @__PURE__ */ new Map();
|
|
1648
|
+
#shims = /* @__PURE__ */ new Map();
|
|
1649
|
+
#workspaceFiles = /* @__PURE__ */ new Map();
|
|
1650
|
+
homeFile(path, content) {
|
|
1651
|
+
addFile(this.#homeFiles, "HOME", path, content);
|
|
1652
|
+
return this;
|
|
1653
|
+
}
|
|
1654
|
+
shim(command, responses) {
|
|
1655
|
+
validateShim(command, responses);
|
|
1656
|
+
if (this.#shims.has(command)) throw new Error(`Shim command is already seeded: ${command}`);
|
|
1657
|
+
this.#shims.set(command, {
|
|
1658
|
+
command,
|
|
1659
|
+
responses: responses.map((response) => ({
|
|
1660
|
+
...response,
|
|
1661
|
+
args: [...response.args]
|
|
1662
|
+
}))
|
|
1663
|
+
});
|
|
1664
|
+
return this;
|
|
1665
|
+
}
|
|
1666
|
+
workspaceFile(path, content) {
|
|
1667
|
+
addFile(this.#workspaceFiles, "workspace", path, content);
|
|
1668
|
+
return this;
|
|
1669
|
+
}
|
|
1670
|
+
async build() {
|
|
1671
|
+
const root = await realpath(await mkdtemp(join(tmpdir(), "aura-testkit-")));
|
|
1672
|
+
const homeDir = join(root, "home");
|
|
1673
|
+
const logDir = join(root, "invocations");
|
|
1674
|
+
const pathDir = join(root, "bin");
|
|
1675
|
+
const workspaceDir = join(root, "workspace");
|
|
1676
|
+
const commands = new Set(this.#shims.keys());
|
|
1677
|
+
try {
|
|
1678
|
+
await Promise.all([
|
|
1679
|
+
mkdir(homeDir, { recursive: true }),
|
|
1680
|
+
mkdir(logDir, { recursive: true }),
|
|
1681
|
+
mkdir(pathDir, { recursive: true }),
|
|
1682
|
+
mkdir(workspaceDir, { recursive: true })
|
|
1683
|
+
]);
|
|
1684
|
+
const roots = {
|
|
1685
|
+
homeDir,
|
|
1686
|
+
workspaceDir
|
|
1687
|
+
};
|
|
1688
|
+
await Promise.all([writeFiles(homeDir, this.#homeFiles.values(), roots), writeFiles(workspaceDir, this.#workspaceFiles.values(), roots)]);
|
|
1689
|
+
await Promise.all([...this.#shims.values()].map((shim) => writeShim({
|
|
1690
|
+
command: shim.command,
|
|
1691
|
+
logDir,
|
|
1692
|
+
pathDir,
|
|
1693
|
+
responses: shim.responses
|
|
1694
|
+
})));
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
await rm(root, {
|
|
1697
|
+
force: true,
|
|
1698
|
+
recursive: true
|
|
1699
|
+
});
|
|
1700
|
+
throw error;
|
|
1701
|
+
}
|
|
1702
|
+
let cleanupPromise;
|
|
1703
|
+
const cleanup = () => {
|
|
1704
|
+
cleanupPromise ??= rm(root, {
|
|
1705
|
+
force: true,
|
|
1706
|
+
recursive: true
|
|
1707
|
+
});
|
|
1708
|
+
return cleanupPromise;
|
|
1709
|
+
};
|
|
1710
|
+
return Object.freeze({
|
|
1711
|
+
cleanup,
|
|
1712
|
+
homeDir,
|
|
1713
|
+
invocations: (command) => commands.has(command) ? readInvocations(logDir, command) : Promise.resolve([]),
|
|
1714
|
+
pathDir,
|
|
1715
|
+
workspaceDir,
|
|
1716
|
+
[Symbol.asyncDispose]: cleanup
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
/** Starts a fluent description of one isolated fake machine. */
|
|
1721
|
+
function createSeedBuilder() {
|
|
1722
|
+
return new SeedBuilder();
|
|
1723
|
+
}
|
|
1724
|
+
function addFile(files, scope, path, content) {
|
|
1725
|
+
const normalized = normalizeSeedPath(path);
|
|
1726
|
+
if (files.has(normalized)) throw new Error(`${scope} file is already seeded: ${normalized}`);
|
|
1727
|
+
files.set(normalized, {
|
|
1728
|
+
content,
|
|
1729
|
+
path: normalized
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
function normalizeSeedPath(path) {
|
|
1733
|
+
if (path.length === 0 || path.includes("\0") || isAbsolute(path)) throw new Error(`Seed file path must be a non-empty relative path. Received: ${path}`);
|
|
1734
|
+
const normalized = normalize(path);
|
|
1735
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith(`..${sep}`)) throw new Error(`Seed file path must stay inside its root. Received: ${path}`);
|
|
1736
|
+
return normalized;
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Materializes one root's files, resolving any content declared as a function of the roots.
|
|
1740
|
+
*
|
|
1741
|
+
* Sequential on purpose: two declared paths can disagree about whether a name is a file or a
|
|
1742
|
+
* directory, and writing them in declaration order makes which one fails deterministic.
|
|
1743
|
+
*/
|
|
1744
|
+
async function writeFiles(root, files, roots) {
|
|
1745
|
+
for (const file of files) {
|
|
1746
|
+
const destination = join(root, file.path);
|
|
1747
|
+
const fromRoot = relative(root, destination);
|
|
1748
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) throw new Error(`Seed file escaped its root: ${file.path}`);
|
|
1749
|
+
const content = typeof file.content === "string" ? file.content : file.content(roots);
|
|
1750
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
1751
|
+
await writeFile(destination, content, "utf8");
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
//#endregion
|
|
1755
|
+
//#region src/mock-directory.ts
|
|
1756
|
+
/** A local `node:http` skill directory speaking the standard protocol, one call per scenario. */
|
|
1757
|
+
function createMockDirectoryBuilder() {
|
|
1758
|
+
const listings = [];
|
|
1759
|
+
const files = /* @__PURE__ */ new Map();
|
|
1760
|
+
const payloads = /* @__PURE__ */ new Map();
|
|
1761
|
+
let requiredToken;
|
|
1762
|
+
const builder = {
|
|
1763
|
+
build: () => start(listings, files, payloads, requiredToken),
|
|
1764
|
+
payloadBytes: (skillId, bytes) => {
|
|
1765
|
+
payloads.set(skillId, bytes);
|
|
1766
|
+
return builder;
|
|
1767
|
+
},
|
|
1768
|
+
rawFileEntry: (skillId, entry) => {
|
|
1769
|
+
const existing = files.get(skillId) ?? [];
|
|
1770
|
+
existing.push(entry);
|
|
1771
|
+
files.set(skillId, existing);
|
|
1772
|
+
return builder;
|
|
1773
|
+
},
|
|
1774
|
+
requireToken: (token) => {
|
|
1775
|
+
requiredToken = token;
|
|
1776
|
+
return builder;
|
|
1777
|
+
},
|
|
1778
|
+
skill: (listing, skillFiles) => {
|
|
1779
|
+
listings.push(listing);
|
|
1780
|
+
const existing = files.get(listing.id) ?? [];
|
|
1781
|
+
existing.push(...skillFiles);
|
|
1782
|
+
files.set(listing.id, existing);
|
|
1783
|
+
return builder;
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
return builder;
|
|
1787
|
+
}
|
|
1788
|
+
async function start(listings, files, payloads, requiredToken) {
|
|
1789
|
+
const requests = [];
|
|
1790
|
+
const server = createServer((request, response) => {
|
|
1791
|
+
requests.push({
|
|
1792
|
+
authorization: request.headers.authorization,
|
|
1793
|
+
method: request.method ?? "",
|
|
1794
|
+
path: request.url ?? ""
|
|
1795
|
+
});
|
|
1796
|
+
respond(request, response, listings, files, payloads, requiredToken);
|
|
1797
|
+
});
|
|
1798
|
+
await new Promise((resolve) => {
|
|
1799
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
1800
|
+
});
|
|
1801
|
+
const address = server.address();
|
|
1802
|
+
const close = () => new Promise((resolve, reject) => {
|
|
1803
|
+
server.close((error) => error === void 0 ? resolve() : reject(error));
|
|
1804
|
+
});
|
|
1805
|
+
return {
|
|
1806
|
+
close,
|
|
1807
|
+
requests,
|
|
1808
|
+
url: `http://127.0.0.1:${String(address.port)}`,
|
|
1809
|
+
[Symbol.asyncDispose]: close
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
function respond(request, response, listings, files, payloads, requiredToken) {
|
|
1813
|
+
if (requiredToken !== void 0 && request.headers.authorization !== `Bearer ${requiredToken}`) {
|
|
1814
|
+
response.writeHead(401, { "content-type": "text/plain" });
|
|
1815
|
+
response.end("unauthorized");
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
const body = request.url === "/index.json" ? JSON.stringify(listings) : skillBody(request.url, listings, files, payloads);
|
|
1819
|
+
if (body === void 0) {
|
|
1820
|
+
response.writeHead(404, { "content-type": "text/plain" });
|
|
1821
|
+
response.end("not found");
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
1825
|
+
response.end(body);
|
|
1826
|
+
}
|
|
1827
|
+
/** The content response for a `/skills/<id>` path, or nothing when the path names no skill. */
|
|
1828
|
+
function skillBody(url, listings, files, payloads) {
|
|
1829
|
+
if (url?.startsWith("/skills/") !== true) return;
|
|
1830
|
+
const skillId = url.slice(8);
|
|
1831
|
+
const payload = payloads.get(skillId);
|
|
1832
|
+
if (payload !== void 0) return "x".repeat(payload);
|
|
1833
|
+
const listing = listings.find((candidate) => candidate.id === skillId);
|
|
1834
|
+
if (listing === void 0) return;
|
|
1835
|
+
return JSON.stringify({
|
|
1836
|
+
...listing,
|
|
1837
|
+
files: files.get(skillId) ?? []
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
//#endregion
|
|
1841
|
+
//#region src/fixtures/claude-code.ts
|
|
1842
|
+
/** The `claude` executable's answers, for composing multi-app seeds on one builder. */
|
|
1843
|
+
function claudeCodeShimResponses(options) {
|
|
1844
|
+
return [{
|
|
1845
|
+
args: ["--version"],
|
|
1846
|
+
stdout: `${options.version} (Claude Code)\n`
|
|
1847
|
+
}, {
|
|
1848
|
+
args: ["auth", "status"],
|
|
1849
|
+
exitCode: options.authenticated ? 0 : 1,
|
|
1850
|
+
stdout: JSON.stringify({ loggedIn: options.authenticated }) + "\n"
|
|
1851
|
+
}];
|
|
1852
|
+
}
|
|
1853
|
+
/** Builds documented Claude Code global and project configuration against an exact CLI version. */
|
|
1854
|
+
function createClaudeCodeSeed(options) {
|
|
1855
|
+
return createSeedBuilder().homeFile(".claude/CLAUDE.md", [
|
|
1856
|
+
"# Global Claude Code instructions",
|
|
1857
|
+
"",
|
|
1858
|
+
"@~/agents/AGENTS.md",
|
|
1859
|
+
"See @team.md for team-specific details.",
|
|
1860
|
+
"Ask @alice about @tryaura/core; neither is an import.",
|
|
1861
|
+
"Keep `@literal.md` as an example.",
|
|
1862
|
+
"```md",
|
|
1863
|
+
"@fenced.md",
|
|
1864
|
+
"```",
|
|
1865
|
+
""
|
|
1866
|
+
].join("\n")).homeFile(".claude/team.md", "# Team instructions\n").homeFile(".claude/settings.json", "{\"permissions\":{\"allow\":[\"Bash(pnpm test)\"]}}\n").homeFile("agents/AGENTS.md", "# Shared agent instructions\n").homeFile(".claude.json", ({ workspaceDir }) => claudeConfiguration(workspaceDir, options.inlineSecrets !== false)).workspaceFile("CLAUDE.md", [
|
|
1867
|
+
"# Project Claude Code instructions",
|
|
1868
|
+
"",
|
|
1869
|
+
"@./docs/project.md",
|
|
1870
|
+
"@~/agents/AGENTS.md",
|
|
1871
|
+
""
|
|
1872
|
+
].join("\n")).workspaceFile("docs/project.md", "# Project-specific instructions\n").workspaceFile(".mcp.json", JSON.stringify({ mcpServers: { projectDocs: {
|
|
1873
|
+
args: ["--project"],
|
|
1874
|
+
command: "project-docs-server",
|
|
1875
|
+
env: { PROJECT_TOKEN: "${PROJECT_TOKEN}" }
|
|
1876
|
+
} } }, void 0, 2) + "\n").shim("claude", claudeCodeShimResponses(options)).shim("npx", [{ args: [] }]).shim("project-docs-server", [{ args: [] }]).build();
|
|
1877
|
+
}
|
|
1878
|
+
function claudeConfiguration(workspaceDir, inlineSecrets) {
|
|
1879
|
+
return JSON.stringify({
|
|
1880
|
+
mcpServers: {
|
|
1881
|
+
docs: {
|
|
1882
|
+
args: ["-y", "@example/docs-mcp"],
|
|
1883
|
+
command: "npx",
|
|
1884
|
+
env: { DOCS_TOKEN: "${DOCS_TOKEN}" }
|
|
1885
|
+
},
|
|
1886
|
+
legacy: {
|
|
1887
|
+
args: [
|
|
1888
|
+
"@example/legacy-mcp",
|
|
1889
|
+
"--api-key",
|
|
1890
|
+
inlineSecrets ? "sk-fixture-secret" : "${LEGACY_TOKEN}"
|
|
1891
|
+
],
|
|
1892
|
+
command: "npx"
|
|
1893
|
+
},
|
|
1894
|
+
sentry: {
|
|
1895
|
+
headers: { Authorization: "Bearer ${SENTRY_TOKEN}" },
|
|
1896
|
+
type: "http",
|
|
1897
|
+
url: "https://mcp.sentry.dev"
|
|
1898
|
+
},
|
|
1899
|
+
streaming: {
|
|
1900
|
+
type: "sse",
|
|
1901
|
+
url: `https://sse.example.com/mcp?token=${inlineSecrets ? "sk-fixture-secret" : "${STREAMING_TOKEN}"}`
|
|
1902
|
+
}
|
|
1903
|
+
},
|
|
1904
|
+
projects: {
|
|
1905
|
+
"/unrelated/project": { mcpServers: { ignored: { command: "ignored" } } },
|
|
1906
|
+
[workspaceDir]: { mcpServers: { local: {
|
|
1907
|
+
headers: { Authorization: "Bearer ${LOCAL_TOKEN}" },
|
|
1908
|
+
type: "http",
|
|
1909
|
+
url: `https://local.example.com/mcp?token=${inlineSecrets ? "sk-local-secret" : "${LOCAL_URL_TOKEN}"}`
|
|
1910
|
+
} } }
|
|
1911
|
+
}
|
|
1912
|
+
}, void 0, 2) + "\n";
|
|
1913
|
+
}
|
|
1914
|
+
//#endregion
|
|
1915
|
+
//#region src/fixtures/codex.ts
|
|
1916
|
+
/** Workspace-relative directory the `"nested"` layout puts its second `AGENTS.md` in. */
|
|
1917
|
+
const CODEX_NESTED_PACKAGE = "packages/app";
|
|
1918
|
+
/** The `codex` executable's answers, for composing multi-app seeds on one builder. */
|
|
1919
|
+
function codexShimResponses(options) {
|
|
1920
|
+
return [{
|
|
1921
|
+
args: ["--version"],
|
|
1922
|
+
stdout: `codex-cli ${options.version}\n`
|
|
1923
|
+
}, {
|
|
1924
|
+
args: ["login", "status"],
|
|
1925
|
+
exitCode: options.authenticated ? 0 : 1,
|
|
1926
|
+
stdout: options.authenticated ? "Logged in using ChatGPT\n" : "Not logged in\n"
|
|
1927
|
+
}];
|
|
1928
|
+
}
|
|
1929
|
+
/** Builds one documented Codex configuration against an exact CLI version. */
|
|
1930
|
+
function createCodexSeed(options) {
|
|
1931
|
+
let builder = createSeedBuilder().homeFile(".codex/AGENTS.md", [
|
|
1932
|
+
"# Global Codex instructions",
|
|
1933
|
+
"",
|
|
1934
|
+
"Follow the shared Aura instructions.",
|
|
1935
|
+
""
|
|
1936
|
+
].join("\n")).homeFile(".codex/config.toml", ({ workspaceDir }) => codexConfiguration(workspaceDir, options.projectTrust)).homeFile("agents/AGENTS.md", "# Shared agent instructions\n").shim("codex", codexShimResponses(options));
|
|
1937
|
+
if (options.projectInstructions !== void 0) builder = builder.workspaceFile("AGENTS.md", instructions("Project"));
|
|
1938
|
+
if (options.projectInstructions === "override") builder = builder.workspaceFile("AGENTS.override.md", instructions("Project override"));
|
|
1939
|
+
if (options.projectInstructions === "nested") builder = builder.workspaceFile(".git", "gitdir: /dev/null\n").workspaceFile(`${CODEX_NESTED_PACKAGE}/AGENTS.md`, instructions("Package"));
|
|
1940
|
+
return builder.build();
|
|
1941
|
+
}
|
|
1942
|
+
function instructions(title) {
|
|
1943
|
+
return [
|
|
1944
|
+
`# ${title} Codex instructions`,
|
|
1945
|
+
"",
|
|
1946
|
+
`Prefer the ${title.toLowerCase()} conventions.`,
|
|
1947
|
+
""
|
|
1948
|
+
].join("\n");
|
|
1949
|
+
}
|
|
1950
|
+
function codexConfiguration(workspaceDir, trust) {
|
|
1951
|
+
return [
|
|
1952
|
+
"model = \"gpt-5\"",
|
|
1953
|
+
"",
|
|
1954
|
+
...trust === void 0 ? [] : [
|
|
1955
|
+
`[projects.${JSON.stringify(workspaceDir)}]`,
|
|
1956
|
+
`trust_level = "${trust}"`,
|
|
1957
|
+
""
|
|
1958
|
+
],
|
|
1959
|
+
"[mcp_servers.docs]",
|
|
1960
|
+
"command = \"npx\"",
|
|
1961
|
+
"args = [\"-y\", \"@example/docs-mcp\"]",
|
|
1962
|
+
"env = { DOCS_TOKEN = \"inline-fixture-secret\" }",
|
|
1963
|
+
"env_vars = [\"LOCAL_TOKEN\", { name = \"REMOTE_TOKEN\", source = \"remote\" }]",
|
|
1964
|
+
"",
|
|
1965
|
+
"[mcp_servers.legacy]",
|
|
1966
|
+
"command = \"npx\"",
|
|
1967
|
+
"args = [\"@example/legacy-mcp\", \"--api-key\", \"sk-fixture-secret\"]",
|
|
1968
|
+
"",
|
|
1969
|
+
"[mcp_servers.sentry]",
|
|
1970
|
+
"url = \"https://mcp.sentry.dev/mcp?token=sk-fixture-secret\"",
|
|
1971
|
+
"bearer_token_env_var = \"SENTRY_TOKEN\"",
|
|
1972
|
+
"http_headers = { \"X-Static\" = \"inline-fixture-secret\" }",
|
|
1973
|
+
"env_http_headers = { Authorization = \"AUTH_TOKEN\" }",
|
|
1974
|
+
"",
|
|
1975
|
+
"[mcp_servers.disabled]",
|
|
1976
|
+
"command = \"ignored\"",
|
|
1977
|
+
"enabled = false",
|
|
1978
|
+
""
|
|
1979
|
+
].join("\n");
|
|
1980
|
+
}
|
|
1981
|
+
//#endregion
|
|
1982
|
+
//#region src/fixtures/cursor.ts
|
|
1983
|
+
/** The `cursor` executable's answers, for composing multi-app seeds on one builder. */
|
|
1984
|
+
function cursorShimResponses(options) {
|
|
1985
|
+
return [{
|
|
1986
|
+
args: ["--version"],
|
|
1987
|
+
stdout: `${options.version}\nfixture-commit\narm64\n`
|
|
1988
|
+
}];
|
|
1989
|
+
}
|
|
1990
|
+
/** Builds documented Cursor rule and MCP configuration against an exact editor version. */
|
|
1991
|
+
function createCursorSeed(options) {
|
|
1992
|
+
let builder = createSeedBuilder().homeFile(".cursor/mcp.json", JSON.stringify({ mcpServers: { docs: {
|
|
1993
|
+
args: ["-y", "@example/docs-mcp"],
|
|
1994
|
+
command: "npx",
|
|
1995
|
+
env: { DOCS_TOKEN: "${env:DOCS_TOKEN}" }
|
|
1996
|
+
} } }, void 0, 2) + "\n").workspaceFile(".cursor/mcp.json", JSON.stringify({ mcpServers: { sentry: {
|
|
1997
|
+
headers: { Authorization: "Bearer ${env:SENTRY_TOKEN}" },
|
|
1998
|
+
url: "https://mcp.sentry.dev/mcp?token=sk-fixture-secret"
|
|
1999
|
+
} } }, void 0, 2) + "\n").shim("cursor", cursorShimResponses(options));
|
|
2000
|
+
if (options.rules === "legacy") builder = builder.workspaceFile(".cursorrules", "# Legacy Cursor instructions\n\nUse the repository conventions.\n").workspaceFile(".cursor/rules/ignored.md", "# Not a Cursor project rule\n");
|
|
2001
|
+
else builder = builder.workspaceFile("AGENTS.md", "# Project agents guide\n\nFollow the workspace conventions.\n").workspaceFile(".cursor/rules/project.mdc", "---\nalwaysApply: true\n---\n\nUse @project-template.ts.\n").workspaceFile(".cursor/rules/project-template.ts", "export const template = true;\n").workspaceFile(".cursor/rules/backend/database.mdc", "---\ndescription: Database conventions\nalwaysApply: false\n---\n\nRead @schema.sql.\n").workspaceFile(".cursor/rules/backend/schema.sql", "select 1;\n").workspaceFile(".cursor/rules/ignored.md", "# Not a Cursor project rule\n");
|
|
2002
|
+
return builder.build();
|
|
2003
|
+
}
|
|
2004
|
+
//#endregion
|
|
2005
|
+
export { ANY_ARGUMENT, CODEX_NESTED_PACKAGE, captureFilesystem, claudeCodeShimResponses, codexShimResponses, createClaudeCodeSeed, createCodexSeed, createCursorSeed, createMockDirectoryBuilder, createSeedBuilder, cursorShimResponses, expectConvergedTwice, loopbackOnlyHttpGet, runBinaryCheck, runCheck, runSetup };
|