agentic-workflow-manager 9.20.1 → 9.20.3
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/dist/src/commands/change/index.js +2 -2
- package/dist/src/commands/changes/index.js +2 -1
- package/dist/src/core/changes/derive.js +21 -8
- package/dist/src/core/changes/git.js +37 -6
- package/dist/src/core/changes/range.js +28 -13
- package/dist/src/core/frontmatter.js +67 -6
- package/dist/src/core/process/model.js +128 -25
- package/dist/tests/commands/change/new.test.js +28 -1
- package/dist/tests/commands/change/validate.test.js +3 -3
- package/dist/tests/commands/changes/list.test.js +1 -0
- package/dist/tests/core/changes/derive.test.js +75 -2
- package/dist/tests/core/changes/git.test.js +83 -1
- package/dist/tests/core/changes/list.test.js +6 -5
- package/dist/tests/core/changes/range.test.js +89 -0
- package/dist/tests/core/frontmatter-hardening.test.js +90 -0
- package/dist/tests/core/process/model.test.js +163 -6
- package/dist/tests/structural/native-release-artifacts.test.js +295 -46
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -58,6 +58,159 @@ describe('parseProcessFrontmatter', () => {
|
|
|
58
58
|
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', 'name: ../../etc/passwd'), 'p');
|
|
59
59
|
expect(r.model).toBeUndefined();
|
|
60
60
|
});
|
|
61
|
+
describe('YAML string slugs (FH-4)', () => {
|
|
62
|
+
const YAML_STRING = /YAML string scalar|quote values YAML reads as null, boolean, or number/i;
|
|
63
|
+
it.each(['null', 'true', '0x1f', '1e5', '01'])('rejects bare YAML non-string name: %s', (raw) => {
|
|
64
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ${raw}`), 'p');
|
|
65
|
+
expect(r.model).toBeUndefined();
|
|
66
|
+
expect(r.diagnostics.join(' ')).toMatch(YAML_STRING);
|
|
67
|
+
expect(r.diagnostics.join(' ')).toMatch(/"name"/);
|
|
68
|
+
});
|
|
69
|
+
it.each([
|
|
70
|
+
['"null"', 'null'],
|
|
71
|
+
['"demo-ok"', 'demo-ok'],
|
|
72
|
+
])('accepts quoted YAML string name %s', (raw, expected) => {
|
|
73
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ${raw}`), 'p');
|
|
74
|
+
expect(r.diagnostics).toEqual([]);
|
|
75
|
+
expect(r.model?.name).toBe(expected);
|
|
76
|
+
});
|
|
77
|
+
it('rejects terminates_to loaded as a non-string', () => {
|
|
78
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('terminates_to: none', 'terminates_to: 0x1f'), 'p');
|
|
79
|
+
expect(r.model).toBeUndefined();
|
|
80
|
+
expect(r.diagnostics.join(' ')).toMatch(YAML_STRING);
|
|
81
|
+
expect(r.diagnostics.join(' ')).toMatch(/"terminates_to"/);
|
|
82
|
+
});
|
|
83
|
+
it.each(['none', '"none"'])('accepts terminates_to %s as none', (raw) => {
|
|
84
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('terminates_to: none', `terminates_to: ${raw}`), 'p');
|
|
85
|
+
expect(r.diagnostics).toEqual([]);
|
|
86
|
+
expect(r.model?.terminatesTo).toBe('none');
|
|
87
|
+
});
|
|
88
|
+
it('rejects a nested-alias bomb in name without calling yaml.load on it', () => {
|
|
89
|
+
// Classic self-referential alias expansion; the pre-load guard must
|
|
90
|
+
// refuse &/* (and never hang) before js-yaml sees the value.
|
|
91
|
+
const bomb = '&a [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]';
|
|
92
|
+
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
93
|
+
try {
|
|
94
|
+
const started = Date.now();
|
|
95
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ${bomb}`), 'p');
|
|
96
|
+
expect(Date.now() - started).toBeLessThan(500);
|
|
97
|
+
expect(r.model).toBeUndefined();
|
|
98
|
+
expect(r.diagnostics.join(' ')).toMatch(YAML_STRING);
|
|
99
|
+
expect(r.diagnostics.join(' ')).toMatch(/"name"/);
|
|
100
|
+
expect(load.mock.calls.some(([arg]) => String(arg).includes(bomb))).toBe(false);
|
|
101
|
+
expect(load.mock.calls.some(([arg]) => /[&*]/.test(String(arg)))).toBe(false);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
load.mockRestore();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
it('rejects a name raw longer than 128 characters without calling yaml.load on it', () => {
|
|
108
|
+
const raw = `"${'a'.repeat(129)}"`;
|
|
109
|
+
expect(raw.length).toBeGreaterThan(128);
|
|
110
|
+
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
111
|
+
try {
|
|
112
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ${raw}`), 'p');
|
|
113
|
+
expect(r.model).toBeUndefined();
|
|
114
|
+
expect(r.diagnostics.join(' ')).toMatch(YAML_STRING);
|
|
115
|
+
expect(load.mock.calls.some(([arg]) => String(arg).includes(raw))).toBe(false);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
load.mockRestore();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
it('accepts name with a trailing plain comment that contains * (guard strips comment)', () => {
|
|
122
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', 'name: my-proc # tag*'), 'p');
|
|
123
|
+
expect(r.diagnostics).toEqual([]);
|
|
124
|
+
expect(r.model?.name).toBe('my-proc');
|
|
125
|
+
});
|
|
126
|
+
it('rejects a description block with an alias-shaped token without calling yaml.load on the block', () => {
|
|
127
|
+
// Alias token lives in a comment so readPairs stays clean; loadBlock must
|
|
128
|
+
// refuse before yaml.load (same alias class as FH-4 slug guard). Slug
|
|
129
|
+
// loads (`v: …`) may still run; the full block must not.
|
|
130
|
+
const withDesc = valid.replace('updated: 2026-08-23', 'updated: 2026-08-23\ndescription: "ok"\n# &hostile_alias');
|
|
131
|
+
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
132
|
+
try {
|
|
133
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDesc, 'p');
|
|
134
|
+
expect(r.model).toBeUndefined();
|
|
135
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" cannot be read: the frontmatter is not valid YAML/);
|
|
136
|
+
expect(load.mock.calls.some(([arg]) => String(arg).includes('&hostile_alias'))).toBe(false);
|
|
137
|
+
expect(load.mock.calls.some(([arg]) => String(arg).includes('description:'))).toBe(false);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
load.mockRestore();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
it('accepts a quoted description with bare & that is not an alias token', () => {
|
|
144
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('updated: 2026-08-23', 'updated: 2026-08-23\ndescription: "foo & bar"'), 'p');
|
|
145
|
+
expect(r.diagnostics).toEqual([]);
|
|
146
|
+
expect(r.model?.description).toBe('foo & bar');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('bounded frontmatter diagnostics (FH-5, FH-6)', () => {
|
|
150
|
+
const FRONTMATTER_MAX = 65536;
|
|
151
|
+
const blockOf = (source) => source.slice(4, source.indexOf('\n---', 4));
|
|
152
|
+
const withBlockLength = (source, target) => {
|
|
153
|
+
const pad = target - blockOf(source).length - 3;
|
|
154
|
+
const next = source.replace('\ncreated:', `\n# ${'x'.repeat(pad)}\ncreated:`);
|
|
155
|
+
expect(blockOf(next)).toHaveLength(target);
|
|
156
|
+
return next;
|
|
157
|
+
};
|
|
158
|
+
it('rejects an oversized process-model block with a size diagnostic and no model', () => {
|
|
159
|
+
const source = withBlockLength(valid, FRONTMATTER_MAX + 1);
|
|
160
|
+
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
161
|
+
try {
|
|
162
|
+
const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
|
|
163
|
+
expect(r.model).toBeUndefined();
|
|
164
|
+
expect(r.diagnostics.join(' ')).toMatch(/frontmatter exceeds 65536 characters/);
|
|
165
|
+
expect(load).not.toHaveBeenCalled();
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
load.mockRestore();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
it('stays silent for an oversized block without the process-model discriminator and never calls yaml.load', () => {
|
|
172
|
+
const source = withBlockLength(valid.replace('awm: process-model\n', ''), FRONTMATTER_MAX + 1);
|
|
173
|
+
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
174
|
+
try {
|
|
175
|
+
const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
|
|
176
|
+
expect(r.model).toBeUndefined();
|
|
177
|
+
expect(r.diagnostics).toEqual([]);
|
|
178
|
+
expect(load).not.toHaveBeenCalled();
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
load.mockRestore();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
it('caps process-model problems at 20 and reports the remainder count', () => {
|
|
185
|
+
const extras = Array.from({ length: 25 }, (_, i) => `unknown_${i}: x`).join('\n');
|
|
186
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', `${extras}\ncreated:`), 'p');
|
|
187
|
+
expect(r.model).toBeUndefined();
|
|
188
|
+
const joined = r.diagnostics.join(' ');
|
|
189
|
+
const unknownMentions = joined.match(/unknown field/g) ?? [];
|
|
190
|
+
expect(unknownMentions.length).toBe(20);
|
|
191
|
+
expect(joined).toContain('5 more problems');
|
|
192
|
+
});
|
|
193
|
+
it('truncates unknown field keys longer than 64 characters with an ellipsis', () => {
|
|
194
|
+
const longKey = `k${'a'.repeat(199)}`;
|
|
195
|
+
expect(longKey).toHaveLength(200);
|
|
196
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', `${longKey}: x\ncreated:`), 'p');
|
|
197
|
+
expect(r.model).toBeUndefined();
|
|
198
|
+
const joined = r.diagnostics.join(' ');
|
|
199
|
+
expect(joined).toMatch(/unknown field/);
|
|
200
|
+
expect(joined).toContain(`${'a'.repeat(63)}…`);
|
|
201
|
+
expect(joined).not.toContain(longKey);
|
|
202
|
+
});
|
|
203
|
+
it('truncates duplicate field keys longer than 64 characters with an ellipsis', () => {
|
|
204
|
+
const longKey = `d${'b'.repeat(199)}`;
|
|
205
|
+
expect(longKey).toHaveLength(200);
|
|
206
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', `${longKey}: a\n${longKey}: b\ncreated:`), 'p');
|
|
207
|
+
expect(r.model).toBeUndefined();
|
|
208
|
+
const joined = r.diagnostics.join(' ');
|
|
209
|
+
expect(joined).toMatch(/duplicate field/);
|
|
210
|
+
expect(joined).toContain(`${'b'.repeat(63)}…`);
|
|
211
|
+
expect(joined).not.toContain(longKey);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
61
214
|
it('nunca lanza ante entrada basura', () => {
|
|
62
215
|
for (const junk of ['', '---\n', '---\n\x00\n---\n', 'no frontmatter']) {
|
|
63
216
|
expect(() => (0, model_1.parseProcessFrontmatter)(junk, 'p')).not.toThrow();
|
|
@@ -208,13 +361,15 @@ describe('parseProcessFrontmatter', () => {
|
|
|
208
361
|
expect(r.diagnostics).toEqual([]);
|
|
209
362
|
expect(r.model?.description).toBe('ok');
|
|
210
363
|
});
|
|
211
|
-
it('rejects a frontmatter block over 65536 characters
|
|
364
|
+
it('rejects a frontmatter block over 65536 characters at the size gate without parsing it', () => {
|
|
365
|
+
// FH-5: process-model discriminator + oversized block → size diagnostic
|
|
366
|
+
// before readPairs / description YAML load (no longer the 1024 path).
|
|
212
367
|
const source = withBlockLength(65537);
|
|
213
368
|
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
214
369
|
try {
|
|
215
370
|
const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
|
|
216
371
|
expect(r.model).toBeUndefined();
|
|
217
|
-
expect(r.diagnostics.join(' ')).toMatch(/
|
|
372
|
+
expect(r.diagnostics.join(' ')).toMatch(/frontmatter exceeds 65536 characters/);
|
|
218
373
|
expect(load).not.toHaveBeenCalled();
|
|
219
374
|
}
|
|
220
375
|
finally {
|
|
@@ -404,13 +559,15 @@ describe('parseProcessFrontmatter', () => {
|
|
|
404
559
|
expect(r.model).toBeUndefined();
|
|
405
560
|
expect(r.diagnostics.join(' ')).not.toMatch(/must not be indented/);
|
|
406
561
|
});
|
|
407
|
-
it('
|
|
562
|
+
it('rejects an oversized indented process-model with a size diagnostic and never calls yaml.load', () => {
|
|
563
|
+
// FH-5 (QA amend): indented awm: process-model in an oversized/unclosed
|
|
564
|
+
// prefix must surface the size diagnostic, not silence.
|
|
408
565
|
const source = indentAll(valid.replace('\ncreated:', `\n# ${'x'.repeat(65536)}\ncreated:`));
|
|
409
566
|
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
410
567
|
try {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
568
|
+
const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
|
|
569
|
+
expect(r.model).toBeUndefined();
|
|
570
|
+
expect(r.diagnostics.join(' ')).toMatch(/frontmatter exceeds 65536 characters/);
|
|
414
571
|
expect(load).not.toHaveBeenCalled();
|
|
415
572
|
}
|
|
416
573
|
finally {
|
|
@@ -5,25 +5,161 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const child_process_1 = require("child_process");
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
9
11
|
const CLI_ROOT = path_1.default.resolve(__dirname, '..', '..');
|
|
10
12
|
const REPO_ROOT = path_1.default.resolve(CLI_ROOT, '..');
|
|
11
13
|
function readWorkflow(name) {
|
|
12
14
|
return fs_1.default.readFileSync(path_1.default.join(REPO_ROOT, '.github', 'workflows', name), 'utf8').replace(/\r\n/g, '\n');
|
|
13
15
|
}
|
|
14
|
-
|
|
16
|
+
const HOT_IGNORE = String.raw `node_modules|[/\\]cli[/\\]tests[/\\]commands[/\\]watch[/\\]track-(finalize|freeze)\.test\.ts$`;
|
|
17
|
+
const CHANGES_PATTERN = String.raw `[/\\]cli[/\\]tests[/\\](core[/\\]changes|commands[/\\]changes?)[/\\]`;
|
|
18
|
+
const HOT_STEP = 'Test hot Windows ARM suites';
|
|
19
|
+
const CHANGES_STEP = 'Test Change Record Windows ARM suites';
|
|
20
|
+
const CHANGES_RUN = `npx jest --runInBand --bail --testPathPatterns='${CHANGES_PATTERN}'`;
|
|
21
|
+
const REMAINDER_COMMENT = " # The ARM runner's slow suites run in two separate jobs (watch track-finalize/freeze, and the Change Record suites). Keep runInBand within each job; no test file may run twice or be dropped.\n";
|
|
22
|
+
const HOT_RUN = 'npx jest --runTestsByPath tests/commands/watch/track-finalize.test.ts tests/commands/watch/track-freeze.test.ts --runInBand --bail';
|
|
23
|
+
const RELEASE_GATE = ['test', 'windows-arm-changes', 'windows-arm-hot'];
|
|
24
|
+
const SHARD_SETUP = [
|
|
25
|
+
{ uses: 'actions/checkout@v4' },
|
|
26
|
+
{ uses: 'actions/setup-node@v4', with: { 'node-version': '22' } },
|
|
27
|
+
{ name: 'Install', 'working-directory': 'cli', run: 'npm ci' },
|
|
28
|
+
{ name: 'Build', 'working-directory': 'cli', run: 'npm run native:build && npm run build' },
|
|
29
|
+
{ name: 'Build test-only native addon', 'working-directory': 'cli', run: 'npm run native:test-build' },
|
|
30
|
+
];
|
|
31
|
+
// Jest compiles --testPathPatterns with the `i` flag but --testPathIgnorePatterns
|
|
32
|
+
// without it, so a directory differing only in case would run in both the shard
|
|
33
|
+
// and the remainder. Returns every directory that falls in that gap.
|
|
34
|
+
function caseOnlyShardPaths(directories, pattern) {
|
|
35
|
+
const insensitive = new RegExp(pattern, 'i');
|
|
36
|
+
const sensitive = new RegExp(pattern);
|
|
37
|
+
return directories.filter((directory) => {
|
|
38
|
+
const candidate = `${directory}/`;
|
|
39
|
+
return insensitive.test(candidate) && !sensitive.test(candidate);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const SHARD_DIRECTORIES = [['core', 'changes'], ['commands', 'change'], ['commands', 'changes']];
|
|
43
|
+
// Each real shard directory must exist with its exact case, hold at least one
|
|
44
|
+
// suite and match CHANGES_PATTERN, so the case-only walk cannot pass vacuously.
|
|
45
|
+
function shardDirectoryProblems(testsRoot) {
|
|
46
|
+
const shard = new RegExp(CHANGES_PATTERN);
|
|
47
|
+
const problems = [];
|
|
48
|
+
for (const segments of SHARD_DIRECTORIES) {
|
|
49
|
+
const label = segments.join('/');
|
|
50
|
+
let current = testsRoot;
|
|
51
|
+
let found = true;
|
|
52
|
+
for (const segment of segments) {
|
|
53
|
+
// readdirSync keeps the on-disk case even on case-insensitive file systems.
|
|
54
|
+
if (!fs_1.default.existsSync(current) || !fs_1.default.readdirSync(current).includes(segment)) {
|
|
55
|
+
found = false;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
current = path_1.default.join(current, segment);
|
|
59
|
+
}
|
|
60
|
+
if (!found) {
|
|
61
|
+
problems.push(`${label}: missing with this exact case`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!fs_1.default.readdirSync(current).some((entry) => entry.endsWith('.test.ts'))) {
|
|
65
|
+
problems.push(`${label}: holds no *.test.ts`);
|
|
66
|
+
}
|
|
67
|
+
if (!shard.test(`${current}${path_1.default.sep}`)) {
|
|
68
|
+
problems.push(`${label}: does not match CHANGES_PATTERN`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return problems;
|
|
72
|
+
}
|
|
73
|
+
function parseJobs(source) {
|
|
74
|
+
const parsed = js_yaml_1.default.load(source);
|
|
75
|
+
expect(parsed?.jobs).toBeDefined();
|
|
76
|
+
return parsed.jobs;
|
|
77
|
+
}
|
|
78
|
+
function getJob(jobs, name) {
|
|
79
|
+
const job = jobs[name];
|
|
80
|
+
expect(job).toBeDefined();
|
|
81
|
+
expect(Array.isArray(job.steps)).toBe(true);
|
|
82
|
+
return job;
|
|
83
|
+
}
|
|
84
|
+
function keysOf(value) {
|
|
85
|
+
return Object.keys(value);
|
|
86
|
+
}
|
|
87
|
+
// The release job publishes only when every test job passed: no if, no
|
|
88
|
+
// continue-on-error, and needs is exactly the three gating jobs in any order.
|
|
89
|
+
function assertReleaseGate(jobs) {
|
|
90
|
+
const release = getJob(jobs, 'release');
|
|
91
|
+
expect(keysOf(release)).not.toContain('if');
|
|
92
|
+
expect(keysOf(release)).not.toContain('continue-on-error');
|
|
93
|
+
for (const step of release.steps) {
|
|
94
|
+
expect(keysOf(step)).not.toContain('continue-on-error');
|
|
95
|
+
}
|
|
96
|
+
expect(Array.isArray(release.needs)).toBe(true);
|
|
97
|
+
const needs = release.needs;
|
|
98
|
+
expect(needs).toHaveLength(RELEASE_GATE.length);
|
|
99
|
+
expect([...needs].sort()).toEqual(RELEASE_GATE);
|
|
100
|
+
}
|
|
101
|
+
// Gate semantics are asserted on the parsed workflow, so key quoting or escaping
|
|
102
|
+
// cannot hide an if or a continue-on-error. Text is checked only where the exact
|
|
103
|
+
// text is the contract: the run lines and the A1 comment.
|
|
104
|
+
function assertArmPartition(source, gatesRelease) {
|
|
15
105
|
const workflow = source.replace(/\r\n/g, '\n');
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
expect(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
106
|
+
const jobs = parseJobs(source);
|
|
107
|
+
const names = keysOf(jobs);
|
|
108
|
+
const test = getJob(jobs, 'test');
|
|
109
|
+
const hot = getJob(jobs, 'windows-arm-hot');
|
|
110
|
+
const changes = getJob(jobs, 'windows-arm-changes');
|
|
111
|
+
expect(names.indexOf('windows-arm-hot')).toBeGreaterThan(names.indexOf('test'));
|
|
112
|
+
expect(names.indexOf('windows-arm-changes')).toBe(names.indexOf('windows-arm-hot') + 1);
|
|
113
|
+
// No gating job tolerates failure, at job or step level.
|
|
114
|
+
for (const job of [test, hot, changes]) {
|
|
115
|
+
expect(keysOf(job)).not.toContain('continue-on-error');
|
|
116
|
+
for (const step of job.steps) {
|
|
117
|
+
expect(keysOf(step)).not.toContain('continue-on-error');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// The ARM shards never skip, at job or step level.
|
|
121
|
+
for (const job of [hot, changes]) {
|
|
122
|
+
expect(keysOf(job)).not.toContain('if');
|
|
123
|
+
for (const step of job.steps) {
|
|
124
|
+
expect(keysOf(step)).not.toContain('if');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// In the matrix, only the two test steps are guarded, with complementary targets.
|
|
128
|
+
const guarded = test.steps.filter((step) => keysOf(step).includes('if'));
|
|
129
|
+
expect(guarded).toHaveLength(2);
|
|
130
|
+
const [regular, remainder] = guarded;
|
|
131
|
+
expect(regular.name).toMatch(/^Tests?$/);
|
|
132
|
+
expect(regular.if).toBe("matrix.target != 'win32-arm64'");
|
|
133
|
+
expect(regular['working-directory']).toBe('cli');
|
|
134
|
+
expect(regular.run).toBe('npx jest --runInBand --bail');
|
|
135
|
+
expect(remainder.name).toMatch(/^Tests? \(Windows ARM remainder\)$/);
|
|
136
|
+
expect(remainder.if).toBe("matrix.target == 'win32-arm64'");
|
|
137
|
+
expect(remainder['working-directory']).toBe('cli');
|
|
138
|
+
const ignorePattern = String(remainder.run).match(/^npx jest --runInBand --bail --testPathIgnorePatterns='([^']+)'$/)?.[1];
|
|
139
|
+
expect(ignorePattern).toBe(`${HOT_IGNORE}|${CHANGES_PATTERN}`);
|
|
140
|
+
expect(test.steps.filter((step) => step.uses === 'actions/upload-artifact@v4')).toHaveLength(1);
|
|
141
|
+
// A1 comment, directly above the remainder step.
|
|
142
|
+
expect(workflow.split(REMAINDER_COMMENT)).toHaveLength(2);
|
|
143
|
+
const aroundRemainder = workflow.split(/ - name: Tests? \(Windows ARM remainder\)\n/);
|
|
144
|
+
expect(aroundRemainder).toHaveLength(2);
|
|
145
|
+
expect(aroundRemainder[0].endsWith(REMAINDER_COMMENT)).toBe(true);
|
|
146
|
+
// Both shards share the same setup; only their test step differs.
|
|
147
|
+
expect(hot['runs-on']).toBe('windows-11-arm');
|
|
148
|
+
expect(hot.steps.slice(0, -1)).toEqual(SHARD_SETUP);
|
|
149
|
+
expect(hot.steps.at(-1)).toEqual({ name: HOT_STEP, 'working-directory': 'cli', run: HOT_RUN });
|
|
150
|
+
expect(changes.steps.at(-1)).toEqual({ name: CHANGES_STEP, 'working-directory': 'cli', run: CHANGES_RUN });
|
|
151
|
+
expect({ ...changes, steps: changes.steps.slice(0, -1) }).toEqual({ ...hot, steps: hot.steps.slice(0, -1) });
|
|
152
|
+
if (gatesRelease) {
|
|
153
|
+
expect(changes.permissions).toEqual({ contents: 'read' });
|
|
154
|
+
}
|
|
155
|
+
for (const job of [hot, changes]) {
|
|
156
|
+
expect(job.steps.some((step) => String(step.uses ?? '').includes('upload-artifact'))).toBe(false);
|
|
157
|
+
expect(JSON.stringify(job)).not.toContain('secure-fs-win32-arm64');
|
|
158
|
+
}
|
|
159
|
+
const lines = workflow.split('\n');
|
|
160
|
+
expect(lines).toContain(` run: ${HOT_RUN}`);
|
|
161
|
+
expect(lines).toContain(` run: ${CHANGES_RUN}`);
|
|
162
|
+
// The remainder ignores exactly the hot suites plus what the Change Record shard runs.
|
|
27
163
|
const ignored = new RegExp(ignorePattern);
|
|
28
164
|
for (const separator of ['/', '\\']) {
|
|
29
165
|
const root = ['repo', 'cli', 'tests'].join(separator);
|
|
@@ -32,18 +168,100 @@ function assertArmPartition(source) {
|
|
|
32
168
|
expect(ignored.test([root, 'other', `${suite}.test.ts`].join(separator))).toBe(false);
|
|
33
169
|
}
|
|
34
170
|
}
|
|
35
|
-
const
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
171
|
+
const shardPattern = String(changes.steps.at(-1)?.run).match(/--testPathPatterns='([^']+)'$/)?.[1];
|
|
172
|
+
expect(shardPattern).toBe(CHANGES_PATTERN);
|
|
173
|
+
expect(ignorePattern).toBe(`${HOT_IGNORE}|${shardPattern}`);
|
|
174
|
+
const shard = new RegExp(shardPattern, 'i');
|
|
175
|
+
for (const separator of ['/', '\\']) {
|
|
176
|
+
const root = ['repo', 'cli', 'tests'].join(separator);
|
|
177
|
+
for (const file of [['core', 'changes', 'x.test.ts'], ['commands', 'change', 'new.test.ts'], ['commands', 'changes', 'list.test.ts']]) {
|
|
178
|
+
const candidate = [root, ...file].join(separator);
|
|
179
|
+
expect(shard.test(candidate)).toBe(true);
|
|
180
|
+
expect(ignored.test(candidate)).toBe(true);
|
|
181
|
+
}
|
|
182
|
+
for (const file of [['core', 'changesets', 'x.test.ts'], ['commands', 'changesets', 'x.test.ts'], ['commands', 'watch', 'track-finalize.test.ts']]) {
|
|
183
|
+
expect(shard.test([root, ...file].join(separator))).toBe(false);
|
|
184
|
+
}
|
|
185
|
+
expect(ignored.test([root, 'core', 'changesets', 'x.test.ts'].join(separator))).toBe(false);
|
|
186
|
+
}
|
|
187
|
+
if (gatesRelease) {
|
|
188
|
+
assertReleaseGate(jobs);
|
|
189
|
+
// CI-2 keeps the comment on the release job's needs line.
|
|
190
|
+
const releaseText = workflow.slice(workflow.search(/^ release:\n/m));
|
|
191
|
+
expect(releaseText.match(/^ needs: [^\n]*$/m)?.[0]).toMatch(/ # rojo en cualquier plataforma o shard ARM no publica$/);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
expect(names).not.toContain('release');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Rewrites the release job's inline `needs` list in the workflow text.
|
|
198
|
+
function rewriteReleaseNeeds(workflow, rewrite) {
|
|
199
|
+
const header = workflow.search(/^ release:\n/m);
|
|
200
|
+
expect(header).toBeGreaterThanOrEqual(0);
|
|
201
|
+
const head = workflow.slice(0, header);
|
|
202
|
+
const tail = workflow.slice(header).replace(/^( needs: )\[([^\]\n]*)\]/m, (_line, prefix, list) => `${prefix}[${rewrite(list.split(',').map((job) => job.trim())).join(', ')}]`);
|
|
203
|
+
return head + tail;
|
|
204
|
+
}
|
|
205
|
+
function permutations(items) {
|
|
206
|
+
if (items.length <= 1)
|
|
207
|
+
return [items];
|
|
208
|
+
return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)]).map((rest) => [item, ...rest]));
|
|
209
|
+
}
|
|
210
|
+
// Every bypass of the partition or the release gate must make assertArmPartition
|
|
211
|
+
// throw: a failed assertion or a mutated text that no longer parses as YAML.
|
|
212
|
+
function assertMutationsRejected(workflow, gates) {
|
|
213
|
+
const jobLevel = (job, line) => workflow.replace(new RegExp(`^ ${job}:\\n`, 'm'), ` ${job}:\n ${line}\n`);
|
|
214
|
+
const inStep = (step, line) => workflow.replace(` - name: ${step}\n`, ` - name: ${step}\n ${line}\n`);
|
|
215
|
+
const mutations = [
|
|
216
|
+
// Swapped matrix guards.
|
|
217
|
+
workflow.replace(/if: matrix\.target (?:!=|==) 'win32-arm64'/g, (guard) => guard.includes('!=') ? "if: matrix.target == 'win32-arm64'" : "if: matrix.target != 'win32-arm64'"),
|
|
218
|
+
// A second producer of the native artifact.
|
|
219
|
+
workflow.replace('uses: actions/upload-artifact@v4', 'uses: actions/upload-artifact@v4\n - uses: actions/upload-artifact@v4'),
|
|
220
|
+
// A remainder that drops more than the shards run.
|
|
221
|
+
workflow.replace(/(--testPathIgnorePatterns='[^']+)(?=')/, '$1|runner\\.test\\.ts$'),
|
|
222
|
+
// A shard pattern that differs from the remainder's exclusion.
|
|
223
|
+
workflow.replace(/(--testPathPatterns='[^']+)changes\?/, '$1changes'),
|
|
224
|
+
// Masked shard runs.
|
|
225
|
+
workflow.replace(/(run: npx jest --runTestsByPath [^\n]+ --runInBand --bail)(?=\n)/, '$1; exit 0'),
|
|
226
|
+
workflow.replace(/(run: npx jest --runInBand --bail --testPathPatterns='[^']+')(?=\n)/, '$1; exit 0'),
|
|
227
|
+
// Skipped steps or jobs, in any key quoting.
|
|
228
|
+
inStep(HOT_STEP, 'if: false'),
|
|
229
|
+
inStep(HOT_STEP, '"if": always()'),
|
|
230
|
+
inStep(HOT_STEP, "'if': false"),
|
|
231
|
+
inStep(CHANGES_STEP, 'if: false'),
|
|
232
|
+
inStep(CHANGES_STEP, '"if": always()'),
|
|
233
|
+
inStep(CHANGES_STEP, "'if': false"),
|
|
234
|
+
inStep('Upload native secure-fs artifact', "'if': false"),
|
|
235
|
+
jobLevel('windows-arm-hot', '"if": always()'),
|
|
236
|
+
jobLevel('windows-arm-hot', "'if': false"),
|
|
237
|
+
jobLevel('windows-arm-changes', '"if": always()'),
|
|
238
|
+
jobLevel('windows-arm-changes', "'if': false"),
|
|
239
|
+
// Tolerated failures, in any key quoting, escaping or expression form.
|
|
240
|
+
jobLevel('test', "continue-on-error: ${{ matrix.target == 'win32-arm64' }}"),
|
|
241
|
+
jobLevel('test', '"continue-on-error": true'),
|
|
242
|
+
jobLevel('test', '"continue\\x2Don-error": true'),
|
|
243
|
+
jobLevel('windows-arm-hot', 'continue-on-error: true'),
|
|
244
|
+
jobLevel('windows-arm-hot', 'continue-on-error: ${{ true }}'),
|
|
245
|
+
jobLevel('windows-arm-hot', '"continue-on-error": true'),
|
|
246
|
+
jobLevel('windows-arm-changes', 'continue-on-error: true'),
|
|
247
|
+
jobLevel('windows-arm-changes', 'continue-on-error: ${{ true }}'),
|
|
248
|
+
jobLevel('windows-arm-changes', '"continue-on-error": true'),
|
|
249
|
+
inStep(HOT_STEP, '"continue-on-error": true'),
|
|
250
|
+
inStep(CHANGES_STEP, "'continue-on-error': true"),
|
|
251
|
+
workflow.replace(/^( run: npx jest --runInBand --bail\n)/m, '$1 continue-on-error: true\n'),
|
|
252
|
+
workflow.replace(/(run: npx jest --runInBand --bail --testPathIgnorePatterns='[^']+'\n)/, '$1 continue-on-error: true\n'),
|
|
253
|
+
workflow.replace(/(run: npx jest --runInBand --bail --testPathIgnorePatterns='[^']+'\n)/, '$1 continue-on-error: ${{ true }}\n'),
|
|
254
|
+
];
|
|
255
|
+
if (gates) {
|
|
256
|
+
mutations.push(rewriteReleaseNeeds(workflow, (jobs) => jobs.filter((job) => job !== 'windows-arm-changes')), rewriteReleaseNeeds(workflow, (jobs) => [...jobs, 'windows-arm-changes']), jobLevel('release', 'if: always()'), jobLevel('release', 'if: ${{ !cancelled() }}'), jobLevel('release', '"if": always()'), jobLevel('release', "'if': false"), jobLevel('release', 'continue-on-error: true'), jobLevel('release', '"continue-on-error": true'),
|
|
257
|
+
// The needs comment is part of CI-2.
|
|
258
|
+
workflow.slice(0, workflow.search(/^ release:\n/m))
|
|
259
|
+
+ workflow.slice(workflow.search(/^ release:\n/m)).replace(/^( needs: [^\n]*?) # rojo en cualquier plataforma o shard ARM no publica$/m, '$1'));
|
|
260
|
+
}
|
|
261
|
+
for (const mutated of mutations) {
|
|
262
|
+
expect(mutated).not.toBe(workflow);
|
|
263
|
+
expect(() => assertArmPartition(mutated, gates)).toThrow();
|
|
264
|
+
}
|
|
47
265
|
}
|
|
48
266
|
describe('native release artifacts', () => {
|
|
49
267
|
it('runs each platform suite once without weakening publication', () => {
|
|
@@ -54,44 +272,75 @@ describe('native release artifacts', () => {
|
|
|
54
272
|
expect(workflow).toContain('run: npx jest --runInBand --bail');
|
|
55
273
|
}
|
|
56
274
|
const release = readWorkflow('release.yml');
|
|
57
|
-
|
|
275
|
+
assertReleaseGate(parseJobs(release));
|
|
58
276
|
for (const target of ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64', 'win32-arm64']) {
|
|
59
277
|
expect(release).toContain(`target: ${target}`);
|
|
60
278
|
}
|
|
61
279
|
expect(release).toContain('cancel-in-progress: false');
|
|
62
280
|
});
|
|
63
|
-
it('partitions the
|
|
281
|
+
it('partitions the hot and Change Record ARM shards without duplicating the native artifact', () => {
|
|
64
282
|
for (const name of ['ci.yml', 'release.yml']) {
|
|
65
283
|
const workflow = readWorkflow(name);
|
|
66
|
-
assertArmPartition(workflow);
|
|
284
|
+
assertArmPartition(workflow, name === 'release.yml');
|
|
67
285
|
}
|
|
68
286
|
});
|
|
69
287
|
it('checks the same partition after a Windows CRLF checkout', () => {
|
|
70
288
|
for (const name of ['ci.yml', 'release.yml']) {
|
|
71
289
|
const workflow = readWorkflow(name);
|
|
72
|
-
assertArmPartition(workflow.replace(/\n/g, '\r\n'));
|
|
290
|
+
assertArmPartition(workflow.replace(/\n/g, '\r\n'), name === 'release.yml');
|
|
73
291
|
}
|
|
74
292
|
});
|
|
75
|
-
it('rejects swapped matrix guards, non-gating
|
|
293
|
+
it('rejects swapped matrix guards, non-gating ARM shards, drifted partitions and duplicate artifacts', () => {
|
|
76
294
|
for (const name of ['ci.yml', 'release.yml']) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
295
|
+
assertMutationsRejected(readWorkflow(name), name === 'release.yml');
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
it('keeps the whole self-test green when release needs is reordered', () => {
|
|
299
|
+
const workflow = readWorkflow('release.yml');
|
|
300
|
+
let current = [];
|
|
301
|
+
rewriteReleaseNeeds(workflow, (jobs) => (current = jobs));
|
|
302
|
+
const reorderedJobs = permutations(current).find((jobs) => jobs.join() !== current.join());
|
|
303
|
+
expect(reorderedJobs).toBeDefined();
|
|
304
|
+
const reordered = rewriteReleaseNeeds(workflow, () => reorderedJobs);
|
|
305
|
+
expect(reordered).not.toBe(workflow);
|
|
306
|
+
expect(() => assertArmPartition(reordered, true)).not.toThrow();
|
|
307
|
+
expect(() => assertMutationsRejected(reordered, true)).not.toThrow();
|
|
308
|
+
});
|
|
309
|
+
it('reports a shard directory that is missing, renamed by case or holds no suite', () => {
|
|
310
|
+
const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-arm-shard-'));
|
|
311
|
+
try {
|
|
312
|
+
const testsRoot = path_1.default.join(tmp, 'cli', 'tests');
|
|
313
|
+
for (const segments of SHARD_DIRECTORIES) {
|
|
314
|
+
fs_1.default.mkdirSync(path_1.default.join(testsRoot, ...segments), { recursive: true });
|
|
315
|
+
fs_1.default.writeFileSync(path_1.default.join(testsRoot, ...segments, 'x.test.ts'), '');
|
|
87
316
|
}
|
|
88
|
-
expect(()
|
|
89
|
-
|
|
90
|
-
expect(()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
expect(()
|
|
94
|
-
|
|
317
|
+
expect(shardDirectoryProblems(testsRoot)).toEqual([]);
|
|
318
|
+
fs_1.default.renameSync(path_1.default.join(testsRoot, 'core', 'changes'), path_1.default.join(testsRoot, 'core', 'Changes'));
|
|
319
|
+
expect(shardDirectoryProblems(testsRoot)).toEqual([expect.stringContaining('core/changes')]);
|
|
320
|
+
fs_1.default.renameSync(path_1.default.join(testsRoot, 'core', 'Changes'), path_1.default.join(testsRoot, 'core', 'changes'));
|
|
321
|
+
fs_1.default.renameSync(path_1.default.join(testsRoot, 'commands', 'change', 'x.test.ts'), path_1.default.join(testsRoot, 'commands', 'change', 'helpers.ts'));
|
|
322
|
+
expect(shardDirectoryProblems(testsRoot)).toEqual([expect.stringContaining('commands/change')]);
|
|
323
|
+
fs_1.default.rmSync(path_1.default.join(testsRoot, 'commands', 'changes'), { recursive: true });
|
|
324
|
+
expect(shardDirectoryProblems(testsRoot)).toHaveLength(2);
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
fs_1.default.rmSync(tmp, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
it('flags test directories that only the case-insensitive shard pattern selects', () => {
|
|
331
|
+
expect(caseOnlyShardPaths(['repo/cli/tests/core/Changes'], CHANGES_PATTERN)).toEqual(['repo/cli/tests/core/Changes']);
|
|
332
|
+
expect(caseOnlyShardPaths(['repo\\cli\\tests\\commands\\CHANGE'], CHANGES_PATTERN)).toEqual(['repo\\cli\\tests\\commands\\CHANGE']);
|
|
333
|
+
expect(caseOnlyShardPaths(['repo/cli/tests/core/changes', 'repo/cli/tests/commands/change', 'repo/cli/tests/core/Changesets'], CHANGES_PATTERN)).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
it('has no test directory selected by the shard but kept by the remainder through case alone', () => {
|
|
336
|
+
const testsRoot = path_1.default.join(CLI_ROOT, 'tests');
|
|
337
|
+
const directories = fs_1.default.readdirSync(testsRoot, { recursive: true })
|
|
338
|
+
.map((relative) => path_1.default.join(testsRoot, relative))
|
|
339
|
+
.filter((candidate) => fs_1.default.statSync(candidate).isDirectory());
|
|
340
|
+
expect(shardDirectoryProblems(testsRoot)).toEqual([]);
|
|
341
|
+
const flagged = caseOnlyShardPaths(directories, CHANGES_PATTERN);
|
|
342
|
+
if (flagged.length > 0) {
|
|
343
|
+
throw new Error(`Case-only Change Record directories would run in both ARM shards: ${flagged.join(', ')}`);
|
|
95
344
|
}
|
|
96
345
|
});
|
|
97
346
|
it('keeps native prebuilds generated instead of versioned source', () => {
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|