agentic-workflow-manager 8.3.0 → 8.4.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/dist/src/commands/doctor.js +40 -1
- package/dist/src/core/dashboard/collect.js +170 -0
- package/dist/src/core/dashboard/plan-state.js +44 -0
- package/dist/src/core/dashboard/render-html.js +82 -0
- package/dist/src/core/dashboard/render-terminal.js +43 -0
- package/dist/src/core/dashboard/sanitize.js +62 -0
- package/dist/src/core/dashboard/styles.js +51 -0
- package/dist/src/core/dashboard/types.js +21 -0
- package/dist/src/core/dashboard/validate.js +106 -0
- package/dist/src/core/dashboard/write-html.js +88 -0
- package/dist/src/core/diagnostics/context.js +1 -1
- package/dist/tests/commands/doctor-is-read-only.test.js +54 -1
- package/dist/tests/commands/doctor.test.js +160 -0
- package/dist/tests/core/dashboard/collect.test.js +173 -0
- package/dist/tests/core/dashboard/contracts.test.js +92 -0
- package/dist/tests/core/dashboard/plan-state.test.js +32 -0
- package/dist/tests/core/dashboard/production-adapters.test.js +70 -0
- package/dist/tests/core/dashboard/render-html.test.js +175 -0
- package/dist/tests/core/dashboard/render-terminal.test.js +65 -0
- package/dist/tests/core/dashboard/write-html.test.js +112 -0
- package/dist/tests/helpers/dashboard-fixtures.js +66 -0
- package/package.json +1 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DashboardValidationError = void 0;
|
|
4
|
+
exports.validateDashboardSnapshotV1 = validateDashboardSnapshotV1;
|
|
5
|
+
const SECTION_ORDER = ['machine', 'project', 'planning', 'execution', 'qa', 'retro', 'history'];
|
|
6
|
+
const ITEM_STATES = ['ok', 'attention', 'missing', 'unavailable', 'not_applicable'];
|
|
7
|
+
const AVAILABILITIES = ['available', 'unavailable', 'not_applicable'];
|
|
8
|
+
const OVERALLS = ['healthy', 'degraded'];
|
|
9
|
+
const CONFIDENCES = ['none', 'provisional', 'observing', 'supported'];
|
|
10
|
+
const ACTIONABLE_STATES = new Set(['attention', 'missing', 'unavailable']);
|
|
11
|
+
class DashboardValidationError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(`Invalid DashboardSnapshotV1: ${message}`);
|
|
14
|
+
this.name = 'DashboardValidationError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.DashboardValidationError = DashboardValidationError;
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function assertRecord(value, path) {
|
|
22
|
+
if (!isRecord(value))
|
|
23
|
+
throw new DashboardValidationError(`${path} must be an object`);
|
|
24
|
+
}
|
|
25
|
+
function assertOnlyKeys(value, keys, path) {
|
|
26
|
+
for (const key of Object.keys(value)) {
|
|
27
|
+
if (!keys.includes(key))
|
|
28
|
+
throw new DashboardValidationError(`${path}.${key} is not supported`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function assertNonEmptyString(value, path) {
|
|
32
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
33
|
+
throw new DashboardValidationError(`${path} must be a non-empty string`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function assertEnum(value, values, path) {
|
|
37
|
+
if (typeof value !== 'string' || !values.includes(value)) {
|
|
38
|
+
throw new DashboardValidationError(`${path} must be one of ${values.join(', ')}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Validates the durable public dashboard boundary without coercing unknown input. */
|
|
42
|
+
function validateDashboardSnapshotV1(value) {
|
|
43
|
+
assertRecord(value, 'snapshot');
|
|
44
|
+
assertOnlyKeys(value, ['schema', 'generatedAt', 'overall', 'project', 'confidence', 'sections'], 'snapshot');
|
|
45
|
+
if (value.schema !== 1)
|
|
46
|
+
throw new DashboardValidationError('schema must be version 1');
|
|
47
|
+
assertNonEmptyString(value.generatedAt, 'generatedAt');
|
|
48
|
+
if (Number.isNaN(Date.parse(value.generatedAt)))
|
|
49
|
+
throw new DashboardValidationError('generatedAt must be a valid date-time');
|
|
50
|
+
assertEnum(value.overall, OVERALLS, 'overall');
|
|
51
|
+
assertEnum(value.confidence, CONFIDENCES, 'confidence');
|
|
52
|
+
assertRecord(value.project, 'project');
|
|
53
|
+
assertOnlyKeys(value.project, ['detected', 'label'], 'project');
|
|
54
|
+
if (typeof value.project.detected !== 'boolean')
|
|
55
|
+
throw new DashboardValidationError('project.detected must be boolean');
|
|
56
|
+
assertNonEmptyString(value.project.label, 'project.label');
|
|
57
|
+
if (!Array.isArray(value.sections))
|
|
58
|
+
throw new DashboardValidationError('sections must be an array');
|
|
59
|
+
const sectionIds = new Set();
|
|
60
|
+
const itemIds = new Set();
|
|
61
|
+
let previousOrder = -1;
|
|
62
|
+
for (const [sectionIndex, section] of value.sections.entries()) {
|
|
63
|
+
const sectionPath = `sections[${sectionIndex}]`;
|
|
64
|
+
assertRecord(section, sectionPath);
|
|
65
|
+
assertOnlyKeys(section, ['id', 'availability', 'items'], sectionPath);
|
|
66
|
+
assertEnum(section.id, SECTION_ORDER, `${sectionPath}.id`);
|
|
67
|
+
const order = SECTION_ORDER.indexOf(section.id);
|
|
68
|
+
if (order <= previousOrder)
|
|
69
|
+
throw new DashboardValidationError('section order must be deterministic');
|
|
70
|
+
previousOrder = order;
|
|
71
|
+
if (sectionIds.has(section.id))
|
|
72
|
+
throw new DashboardValidationError(`duplicate section id ${section.id}`);
|
|
73
|
+
sectionIds.add(section.id);
|
|
74
|
+
assertEnum(section.availability, AVAILABILITIES, `${sectionPath}.availability`);
|
|
75
|
+
if (!Array.isArray(section.items))
|
|
76
|
+
throw new DashboardValidationError(`${sectionPath}.items must be an array`);
|
|
77
|
+
for (const [itemIndex, item] of section.items.entries()) {
|
|
78
|
+
const itemPath = `${sectionPath}.items[${itemIndex}]`;
|
|
79
|
+
assertRecord(item, itemPath);
|
|
80
|
+
assertOnlyKeys(item, ['id', 'label', 'state', 'detail', 'remediation'], itemPath);
|
|
81
|
+
assertNonEmptyString(item.id, `${itemPath}.id`);
|
|
82
|
+
if (itemIds.has(item.id))
|
|
83
|
+
throw new DashboardValidationError(`duplicate item id ${item.id}`);
|
|
84
|
+
itemIds.add(item.id);
|
|
85
|
+
assertNonEmptyString(item.label, `${itemPath}.label`);
|
|
86
|
+
assertEnum(item.state, ITEM_STATES, `${itemPath}.state`);
|
|
87
|
+
if (item.detail !== undefined)
|
|
88
|
+
assertNonEmptyString(item.detail, `${itemPath}.detail`);
|
|
89
|
+
if (item.remediation !== undefined)
|
|
90
|
+
assertNonEmptyString(item.remediation, `${itemPath}.remediation`);
|
|
91
|
+
if (ACTIONABLE_STATES.has(item.state) && item.remediation === undefined) {
|
|
92
|
+
throw new DashboardValidationError(`${itemPath}.remediation is required for actionable state`);
|
|
93
|
+
}
|
|
94
|
+
if (item.state === 'not_applicable' && item.remediation !== undefined) {
|
|
95
|
+
throw new DashboardValidationError(`${itemPath}.remediation is forbidden for not_applicable state`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!value.project.detected && (value.sections.length !== 1 || value.sections[0].id !== 'machine')) {
|
|
100
|
+
throw new DashboardValidationError('no project snapshot may contain only the machine section');
|
|
101
|
+
}
|
|
102
|
+
if (value.project.detected && sectionIds.size !== SECTION_ORDER.length) {
|
|
103
|
+
throw new DashboardValidationError('detected project snapshot must contain the canonical lifecycle sections');
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveHtmlTarget = resolveHtmlTarget;
|
|
7
|
+
exports.writeHtmlAtomically = writeHtmlAtomically;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const crypto_1 = require("crypto");
|
|
11
|
+
function resolveHtmlTarget(input, operations = fs_1.default) {
|
|
12
|
+
if (!input || typeof input.cwd !== 'string' || input.cwd.trim() === '' || input.cwd.includes('\0') || typeof input.target !== 'string' || input.target.trim() === '' || input.target.includes('\0') || input.target.startsWith('--'))
|
|
13
|
+
throw new Error('--html requires a file target');
|
|
14
|
+
if (input.force !== undefined && typeof input.force !== 'boolean')
|
|
15
|
+
throw new Error('--force must be boolean');
|
|
16
|
+
const target = path_1.default.isAbsolute(input.target) ? path_1.default.normalize(input.target) : path_1.default.resolve(input.cwd, input.target);
|
|
17
|
+
const parent = path_1.default.dirname(target);
|
|
18
|
+
if (!operations.existsSync(parent) || !operations.statSync(parent).isDirectory())
|
|
19
|
+
throw new Error(`HTML parent directory does not exist: ${parent}`);
|
|
20
|
+
for (let current = parent;; current = path_1.default.dirname(current)) {
|
|
21
|
+
if (operations.lstatSync(current).isSymbolicLink())
|
|
22
|
+
throw new Error('HTML parent directory must not contain a symbolic link');
|
|
23
|
+
if (current === path_1.default.parse(current).root)
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
operations.accessSync(parent, fs_1.default.constants.W_OK);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error(`HTML parent directory is not writable: ${parent}`);
|
|
31
|
+
}
|
|
32
|
+
let stat;
|
|
33
|
+
try {
|
|
34
|
+
stat = operations.lstatSync(target);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return target;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
42
|
+
throw new Error('HTML target must be a regular file');
|
|
43
|
+
if (!input.force)
|
|
44
|
+
throw new Error(`HTML target already exists: ${target}; use --force`);
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
function writeHtmlAtomically(input, operations = fs_1.default) {
|
|
48
|
+
if (!input || typeof input.cwd !== 'string' || input.cwd.trim() === '' || input.cwd.includes('\0'))
|
|
49
|
+
throw new Error('writeHtmlAtomically requires a non-empty cwd');
|
|
50
|
+
if (typeof input.target !== 'string' || input.target.trim() === '' || !path_1.default.isAbsolute(input.target))
|
|
51
|
+
throw new Error('writeHtmlAtomically requires a non-empty absolute target');
|
|
52
|
+
if (input.target.includes('\0'))
|
|
53
|
+
throw new Error('writeHtmlAtomically target must not contain NUL');
|
|
54
|
+
if (typeof input.html !== 'string' || input.html.length === 0)
|
|
55
|
+
throw new Error('writeHtmlAtomically requires non-empty html');
|
|
56
|
+
if (input.force !== undefined && typeof input.force !== 'boolean')
|
|
57
|
+
throw new Error('writeHtmlAtomically force must be boolean');
|
|
58
|
+
if (operations.existsSync(input.target) && !input.force)
|
|
59
|
+
throw new Error(`HTML target already exists: ${input.target}; use --force`);
|
|
60
|
+
const platform = input.platform ?? process.platform;
|
|
61
|
+
if (typeof platform !== 'string' || !/^[a-z0-9]+$/i.test(platform))
|
|
62
|
+
throw new Error('writeHtmlAtomically platform must be a valid platform');
|
|
63
|
+
const temp = path_1.default.join(path_1.default.dirname(input.target), `.${path_1.default.basename(input.target)}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`);
|
|
64
|
+
let fd;
|
|
65
|
+
let tempCreated = false;
|
|
66
|
+
try {
|
|
67
|
+
fd = platform === 'win32' ? operations.openSync(temp, 'wx') : operations.openSync(temp, 'wx', 0o600);
|
|
68
|
+
tempCreated = true;
|
|
69
|
+
operations.writeFileSync(fd, input.html, 'utf8');
|
|
70
|
+
operations.fsyncSync(fd);
|
|
71
|
+
operations.closeSync(fd);
|
|
72
|
+
fd = undefined;
|
|
73
|
+
operations.renameSync(temp, input.target);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (fd !== undefined)
|
|
77
|
+
try {
|
|
78
|
+
operations.closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
catch { /* best effort */ }
|
|
81
|
+
try {
|
|
82
|
+
if (tempCreated && operations.existsSync(temp))
|
|
83
|
+
operations.unlinkSync(temp);
|
|
84
|
+
}
|
|
85
|
+
catch { /* known temp only */ }
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -297,7 +297,7 @@ function gatherProject(root, bundles, agent = 'claude-code') {
|
|
|
297
297
|
context = { present: true, file: 'AGENTS.md' };
|
|
298
298
|
return {
|
|
299
299
|
root,
|
|
300
|
-
profile: { present: profilePresent, extensions: profile.extensions },
|
|
300
|
+
profile: { present: profilePresent, extensions: profile.extensions, registries: profile.registries },
|
|
301
301
|
activeBundles: { expected, linked, broken },
|
|
302
302
|
orphanLinks,
|
|
303
303
|
sensors: { present: fs_1.default.existsSync(path_1.default.join(root, '.awm', 'sensors.json')) },
|
|
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
21
21
|
const fs_1 = __importDefault(require("fs"));
|
|
22
22
|
const os_1 = __importDefault(require("os"));
|
|
23
23
|
const path_1 = __importDefault(require("path"));
|
|
24
|
+
const child_process_1 = require("child_process");
|
|
24
25
|
describe('awm doctor no escribe nada', () => {
|
|
25
26
|
let home;
|
|
26
27
|
let projectRoot;
|
|
@@ -32,7 +33,8 @@ describe('awm doctor no escribe nada', () => {
|
|
|
32
33
|
process.env.HOME = home;
|
|
33
34
|
process.env.AWM_HOME = path_1.default.join(home, '.awm');
|
|
34
35
|
projectRoot = path_1.default.join(home, 'proj');
|
|
35
|
-
fs_1.default.mkdirSync(
|
|
36
|
+
fs_1.default.mkdirSync(projectRoot, { recursive: true });
|
|
37
|
+
(0, child_process_1.execFileSync)('git', ['init', '-q', projectRoot]);
|
|
36
38
|
jest.resetModules();
|
|
37
39
|
writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
38
40
|
});
|
|
@@ -62,6 +64,20 @@ describe('awm doctor no escribe nada', () => {
|
|
|
62
64
|
walk('.');
|
|
63
65
|
return out.sort();
|
|
64
66
|
};
|
|
67
|
+
const bytesOf = (dir) => {
|
|
68
|
+
const out = [];
|
|
69
|
+
const walk = (sub) => {
|
|
70
|
+
for (const entry of fs_1.default.readdirSync(path_1.default.join(dir, sub), { withFileTypes: true })) {
|
|
71
|
+
const rel = path_1.default.join(sub, entry.name);
|
|
72
|
+
if (entry.isDirectory())
|
|
73
|
+
walk(rel);
|
|
74
|
+
else
|
|
75
|
+
out.push([rel, fs_1.default.readFileSync(path_1.default.join(dir, rel)).toString('base64')]);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
walk('.');
|
|
79
|
+
return out.sort(([left], [right]) => left.localeCompare(right));
|
|
80
|
+
};
|
|
65
81
|
it('no crea AWM_HOME ni preferences.json en una máquina limpia', () => {
|
|
66
82
|
const { runDoctor } = require('../../src/commands/doctor');
|
|
67
83
|
const before = treeOf(home);
|
|
@@ -94,4 +110,41 @@ describe('awm doctor no escribe nada', () => {
|
|
|
94
110
|
const emitted = writeSpy.mock.calls.map((c) => String(c[0])).join('');
|
|
95
111
|
expect(JSON.parse(emitted).providers.map((p) => p.id)).toEqual(['claude-code']);
|
|
96
112
|
});
|
|
113
|
+
it('collecting the dashboard leaves project, preferences, journal, ledger, and git bytes unchanged', () => {
|
|
114
|
+
fs_1.default.mkdirSync(path_1.default.join(home, '.awm', 'ledger'), { recursive: true });
|
|
115
|
+
fs_1.default.writeFileSync(path_1.default.join(home, '.awm', 'preferences.json'), '{"defaultAgent":"claude-code","enabledAgents":["claude-code"],"installMethod":"symlink","defaultScope":"local"}\n');
|
|
116
|
+
fs_1.default.writeFileSync(path_1.default.join(home, '.awm', 'ledger', 'events.jsonl'), '{"event":"before"}\n');
|
|
117
|
+
fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm', 'journal'), { recursive: true });
|
|
118
|
+
fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), '{"extensions":[]}\n');
|
|
119
|
+
fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'journal', 'state.json'), '{"state":"active"}\n');
|
|
120
|
+
const before = { home: bytesOf(home), project: bytesOf(projectRoot), git: (() => { try {
|
|
121
|
+
return (0, child_process_1.execFileSync)('git', ['status', '--porcelain=v1'], { cwd: projectRoot, encoding: 'utf8' });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return String(error);
|
|
125
|
+
} })() };
|
|
126
|
+
const { collectDashboardSnapshot } = require('../../src/core/dashboard/collect');
|
|
127
|
+
collectDashboardSnapshot({ cwd: projectRoot, now: '2026-08-22T00:00:00.000Z' });
|
|
128
|
+
const after = { home: bytesOf(home), project: bytesOf(projectRoot), git: (() => { try {
|
|
129
|
+
return (0, child_process_1.execFileSync)('git', ['status', '--porcelain=v1'], { cwd: projectRoot, encoding: 'utf8' });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
return String(error);
|
|
133
|
+
} })() };
|
|
134
|
+
expect(after).toEqual(before);
|
|
135
|
+
});
|
|
136
|
+
it('--html replaces only its explicitly requested target', () => {
|
|
137
|
+
const target = path_1.default.join(projectRoot, 'dashboard.html');
|
|
138
|
+
fs_1.default.writeFileSync(target, 'old dashboard');
|
|
139
|
+
const before = { home: bytesOf(home), project: bytesOf(projectRoot) };
|
|
140
|
+
const { runDoctor } = require('../../src/commands/doctor');
|
|
141
|
+
const code = runDoctor({ cwd: projectRoot, html: target, force: true });
|
|
142
|
+
const after = { home: bytesOf(home), project: bytesOf(projectRoot) };
|
|
143
|
+
expect([0, 1]).toContain(code);
|
|
144
|
+
const targetFromHome = path_1.default.join('proj', 'dashboard.html');
|
|
145
|
+
expect(after.home.filter(([name]) => name !== targetFromHome)).toEqual(before.home.filter(([name]) => name !== targetFromHome));
|
|
146
|
+
expect(after.project.filter(([name]) => name !== 'dashboard.html')).toEqual(before.project.filter(([name]) => name !== 'dashboard.html'));
|
|
147
|
+
expect(fs_1.default.readFileSync(target, 'utf8')).not.toBe('old dashboard');
|
|
148
|
+
expect(after.project.map(([name]) => name).filter((name) => name.includes('.tmp'))).toEqual([]);
|
|
149
|
+
});
|
|
97
150
|
});
|
|
@@ -4,9 +4,169 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const doctor_1 = require("../../src/commands/doctor");
|
|
7
|
+
const commander_1 = require("commander");
|
|
7
8
|
const fs_1 = __importDefault(require("fs"));
|
|
8
9
|
const os_1 = __importDefault(require("os"));
|
|
9
10
|
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const dashboard_fixtures_1 = require("../helpers/dashboard-fixtures");
|
|
12
|
+
const types_1 = require("../../src/core/dashboard/types");
|
|
13
|
+
const render_html_1 = require("../../src/core/dashboard/render-html");
|
|
14
|
+
describe('runDoctor legacy JSON fixtures', () => {
|
|
15
|
+
it.each(['bare-home', 'project'])('keeps %s JSON byte-for-byte compatible', (kind) => {
|
|
16
|
+
const captured = (0, dashboard_fixtures_1.captureDoctorJsonFixture)(kind);
|
|
17
|
+
try {
|
|
18
|
+
const expected = fs_1.default.readFileSync(path_1.default.join(__dirname, '..', 'fixtures', 'doctor-json', `${kind}.json`), 'utf-8');
|
|
19
|
+
expect(captured.output).toBe(expected);
|
|
20
|
+
expect(captured.code).toBe(1);
|
|
21
|
+
const parsed = JSON.parse(captured.output);
|
|
22
|
+
expect(parsed).toEqual(expect.objectContaining({
|
|
23
|
+
overall: 'degraded',
|
|
24
|
+
providers: expect.any(Array),
|
|
25
|
+
}));
|
|
26
|
+
const provider = parsed.providers[0];
|
|
27
|
+
expect(provider).toEqual(expect.objectContaining({
|
|
28
|
+
id: 'copilot',
|
|
29
|
+
label: 'Copilot',
|
|
30
|
+
tier: 'agents-md-managed',
|
|
31
|
+
checks: expect.any(Array),
|
|
32
|
+
}));
|
|
33
|
+
expect(provider.checks).toEqual(expect.arrayContaining([
|
|
34
|
+
expect.objectContaining({ id: 'context.global', state: kind === 'project' ? 'stale' : 'absent' }),
|
|
35
|
+
]));
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
captured.cleanup();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('runDoctor dashboard modes', () => {
|
|
43
|
+
it('CLI rejects --html without an argument before collection or writes', async () => {
|
|
44
|
+
const program = new commander_1.Command();
|
|
45
|
+
const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
46
|
+
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
47
|
+
const previousExitCode = process.exitCode;
|
|
48
|
+
try {
|
|
49
|
+
(0, doctor_1.registerDoctorCommand)(program);
|
|
50
|
+
await program.parseAsync(['node', 'awm', 'doctor', '--html']);
|
|
51
|
+
expect(process.exitCode).toBe(2);
|
|
52
|
+
expect(stderr.mock.calls.map((call) => String(call[0])).join('')).toContain('--html requires a file target');
|
|
53
|
+
expect(stdout).not.toHaveBeenCalled();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
process.exitCode = previousExitCode;
|
|
57
|
+
stderr.mockRestore();
|
|
58
|
+
stdout.mockRestore();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
it.each([
|
|
62
|
+
[{ json: true, full: true }, '--json cannot be combined with --full'],
|
|
63
|
+
[{ json: true, html: 'report.html' }, '--json cannot be combined with --html'],
|
|
64
|
+
[{ full: true, html: 'report.html' }, '--full cannot be combined with --html'],
|
|
65
|
+
[{ force: true }, '--force requires --html'],
|
|
66
|
+
[{ html: '' }, '--html requires a file target'],
|
|
67
|
+
])('rejects incompatible options before collection', (options, message) => {
|
|
68
|
+
const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
69
|
+
try {
|
|
70
|
+
expect((0, doctor_1.runDoctor)({ ...options, collectSnapshot: () => { throw new Error('collection must not run'); } })).toBe(2);
|
|
71
|
+
expect(stderr.mock.calls.map((call) => String(call[0])).join('')).toContain(message);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
stderr.mockRestore();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it('writes exact dashboard bytes and only prints the final path after successful --html', () => {
|
|
78
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-html-success-'));
|
|
79
|
+
const target = path_1.default.join(root, 'dashboard.html');
|
|
80
|
+
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
81
|
+
const snapshot = (0, types_1.dashboardSnapshot)();
|
|
82
|
+
try {
|
|
83
|
+
expect((0, doctor_1.runDoctor)({ cwd: root, html: target, collectSnapshot: () => snapshot })).toBe(0);
|
|
84
|
+
expect(fs_1.default.readFileSync(target, 'utf8')).toBe((0, render_html_1.renderDashboardHtml)(snapshot));
|
|
85
|
+
expect(stdout.mock.calls.map((call) => String(call[0]))).toEqual([`${target}\n`]);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
stdout.mockRestore();
|
|
89
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
it('maps healthy full, invalid, and failing HTML modes to 0, 2, and 2', () => {
|
|
93
|
+
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
94
|
+
const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
95
|
+
try {
|
|
96
|
+
expect((0, doctor_1.runDoctor)({ full: true, cwd: '/definitely-not-a-project', collectSnapshot: () => (0, types_1.dashboardSnapshot)() })).toBe(0);
|
|
97
|
+
expect((0, doctor_1.runDoctor)({ html: '' })).toBe(2);
|
|
98
|
+
expect((0, doctor_1.runDoctor)({ html: '/definitely-missing-parent/report.html' })).toBe(2);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
stdout.mockRestore();
|
|
102
|
+
stderr.mockRestore();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
it.each([{ full: true }, { html: 'report.html', force: true }])('returns 1 for a degraded dashboard mode', (options) => {
|
|
106
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-degraded-'));
|
|
107
|
+
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
108
|
+
try {
|
|
109
|
+
expect((0, doctor_1.runDoctor)({ ...options, cwd: root, collectSnapshot: () => (0, types_1.dashboardSnapshot)({ overall: 'degraded' }) })).toBe(1);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
stdout.mockRestore();
|
|
113
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
it('renders a real read-only machine finding and its remediation in full mode', () => {
|
|
117
|
+
const home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-real-dashboard-'));
|
|
118
|
+
const previousHome = process.env.HOME;
|
|
119
|
+
const previousAwmHome = process.env.AWM_HOME;
|
|
120
|
+
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
121
|
+
try {
|
|
122
|
+
process.env.HOME = home;
|
|
123
|
+
process.env.AWM_HOME = path_1.default.join(home, '.awm');
|
|
124
|
+
expect((0, doctor_1.runDoctor)({ full: true, cwd: home })).toBe(1);
|
|
125
|
+
const output = stdout.mock.calls.map((call) => String(call[0])).join('');
|
|
126
|
+
expect(output).toContain('machine.preferences.missing');
|
|
127
|
+
expect(output).toContain('awm init');
|
|
128
|
+
expect(fs_1.default.existsSync(path_1.default.join(home, '.awm', 'preferences.json'))).toBe(false);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
stdout.mockRestore();
|
|
132
|
+
fs_1.default.rmSync(home, { recursive: true, force: true });
|
|
133
|
+
if (previousHome === undefined)
|
|
134
|
+
delete process.env.HOME;
|
|
135
|
+
else
|
|
136
|
+
process.env.HOME = previousHome;
|
|
137
|
+
if (previousAwmHome === undefined)
|
|
138
|
+
delete process.env.AWM_HOME;
|
|
139
|
+
else
|
|
140
|
+
process.env.AWM_HOME = previousAwmHome;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
it('wires existing read-only project facts into the dashboard without inventing lifecycle observations', () => {
|
|
144
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-project-facts-'));
|
|
145
|
+
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
146
|
+
try {
|
|
147
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}');
|
|
148
|
+
expect((0, doctor_1.runDoctor)({ full: true, cwd: root })).toBe(1);
|
|
149
|
+
const output = stdout.mock.calls.map((call) => String(call[0])).join('');
|
|
150
|
+
expect(output).toContain('project.profile.missing');
|
|
151
|
+
expect(output).toContain('project.sensors.unavailable');
|
|
152
|
+
expect(output).toContain('unavailable');
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
stdout.mockRestore();
|
|
156
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
it('applies doctor agent selection validation to full dashboard mode', () => {
|
|
160
|
+
const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
161
|
+
try {
|
|
162
|
+
expect((0, doctor_1.runDoctor)({ full: true, cwd: '/definitely-not-a-project', agent: 'not-a-real-agent' })).toBe(2);
|
|
163
|
+
expect(stderr.mock.calls.map((call) => String(call[0])).join('')).toContain('Invalid agent');
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
stderr.mockRestore();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
10
170
|
function report(partial = {}) {
|
|
11
171
|
return {
|
|
12
172
|
results: [
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const collect_1 = require("../../../src/core/dashboard/collect");
|
|
4
|
+
const sanitize_1 = require("../../../src/core/dashboard/sanitize");
|
|
5
|
+
const fixedNow = '2026-08-22T00:00:00.000Z';
|
|
6
|
+
describe('collectDashboardSnapshot', () => {
|
|
7
|
+
it('returns only a healthy machine section outside a project', () => {
|
|
8
|
+
const project = jest.fn();
|
|
9
|
+
const plans = jest.fn();
|
|
10
|
+
const execution = jest.fn();
|
|
11
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
12
|
+
cwd: '/definitely-not-a-project', now: fixedNow,
|
|
13
|
+
adapters: { machine: () => ({ findings: [] }), project, plans, execution },
|
|
14
|
+
});
|
|
15
|
+
expect(snapshot.project).toEqual({ detected: false, label: 'No project detected' });
|
|
16
|
+
expect(snapshot.sections.map((section) => section.id)).toEqual(['machine']);
|
|
17
|
+
expect(snapshot.overall).toBe('healthy');
|
|
18
|
+
expect(project).not.toHaveBeenCalled();
|
|
19
|
+
expect(plans).not.toHaveBeenCalled();
|
|
20
|
+
expect(execution).not.toHaveBeenCalled();
|
|
21
|
+
});
|
|
22
|
+
it('fails loudly for malformed central machine findings', () => {
|
|
23
|
+
expect(() => (0, collect_1.collectDashboardSnapshot)({ cwd: '/definitely-not-a-project', now: fixedNow, adapters: { machine: () => ({ findings: [{ id: '', label: 'Preferences', state: 'ok' }] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn() } })).toThrow(/finding/i);
|
|
24
|
+
});
|
|
25
|
+
it.each([undefined, null, {}, 'healthy'])('rejects a non-array central machine findings value: %p', (findings) => {
|
|
26
|
+
expect(() => (0, collect_1.collectDashboardSnapshot)({
|
|
27
|
+
cwd: '/definitely-not-a-project', now: fixedNow,
|
|
28
|
+
adapters: { machine: () => ({ findings }), project: jest.fn(), plans: jest.fn(), execution: jest.fn() },
|
|
29
|
+
})).toThrow('Dashboard findings must be an array');
|
|
30
|
+
});
|
|
31
|
+
it('uses exact verified remediation commands and stable ordered sections', () => {
|
|
32
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
33
|
+
cwd: process.cwd(), now: fixedNow,
|
|
34
|
+
adapters: {
|
|
35
|
+
machine: () => ({ findings: [{ id: 'machine.preferences.missing', label: 'Preferences', state: 'missing' }] }),
|
|
36
|
+
project: () => ({ label: 'Demo', findings: [{ id: 'project.profile.missing', label: 'Profile', state: 'missing' }] }),
|
|
37
|
+
plans: () => Array.from({ length: 2000 }, (_, index) => ({ id: `plan.${index}`, label: `Plan ${index}`, state: 'ok' })),
|
|
38
|
+
execution: () => ({ history: Array.from({ length: 500 }, (_, index) => ({ id: `history.${index}`, label: `History ${index}`, state: 'ok' })) }),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
expect(snapshot.sections.map((section) => section.id)).toEqual(['machine', 'project', 'planning', 'execution', 'qa', 'retro', 'history']);
|
|
42
|
+
expect(snapshot.sections.find((section) => section.id === 'machine')?.items[0].remediation).toBe('awm init');
|
|
43
|
+
expect(snapshot.sections.find((section) => section.id === 'planning')?.items).toHaveLength(2000);
|
|
44
|
+
expect(snapshot.sections.find((section) => section.id === 'history')?.items).toHaveLength(500);
|
|
45
|
+
expect(JSON.stringify(snapshot)).not.toMatch(/score|ranking/i);
|
|
46
|
+
});
|
|
47
|
+
it('isolates optional adapter failures and omits unverified remediation', () => {
|
|
48
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
49
|
+
cwd: process.cwd(), now: fixedNow,
|
|
50
|
+
adapters: {
|
|
51
|
+
machine: () => ({ findings: [{ id: 'unknown', label: 'Unknown', state: 'missing' }] }),
|
|
52
|
+
project: () => { throw new Error('corrupt project source'); },
|
|
53
|
+
plans: () => [], execution: () => undefined,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
expect(snapshot.sections.find((section) => section.id === 'machine')?.items).toEqual([]);
|
|
57
|
+
expect(snapshot.sections.find((section) => section.id === 'project')?.availability).toBe('unavailable');
|
|
58
|
+
expect(snapshot.sections.find((section) => section.id === 'project')?.items).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
it('marks execution-derived sections unavailable when no read-only execution source exists', () => {
|
|
61
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
62
|
+
cwd: process.cwd(), now: fixedNow,
|
|
63
|
+
adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined },
|
|
64
|
+
});
|
|
65
|
+
for (const id of ['execution', 'qa', 'retro', 'history']) {
|
|
66
|
+
expect(snapshot.sections.find((section) => section.id === id)?.availability).toBe('unavailable');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it('isolates malformed optional source data to its owning section', () => {
|
|
70
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
71
|
+
cwd: process.cwd(), now: fixedNow,
|
|
72
|
+
adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [{ id: 'bad', label: 'Profile', state: 'invented' }] }), plans: () => [], execution: () => undefined },
|
|
73
|
+
});
|
|
74
|
+
expect(snapshot.sections.find((section) => section.id === 'project')?.availability).toBe('unavailable');
|
|
75
|
+
expect(snapshot.sections.find((section) => section.id === 'machine')?.availability).toBe('available');
|
|
76
|
+
});
|
|
77
|
+
it('isolates malformed post-sanitization plan findings', () => {
|
|
78
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [{ id: '', label: 'Profile', state: 'ok' }], execution: () => undefined } });
|
|
79
|
+
expect(snapshot.sections.find((section) => section.id === 'planning')?.availability).toBe('unavailable');
|
|
80
|
+
});
|
|
81
|
+
it('isolates malformed project findings without dropping other sections', () => {
|
|
82
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [{ id: '', label: 'Profile', state: 'ok' }] }), plans: () => [], execution: () => undefined } });
|
|
83
|
+
expect(snapshot.sections.find((section) => section.id === 'project')?.availability).toBe('unavailable');
|
|
84
|
+
expect(snapshot.sections.find((section) => section.id === 'planning')?.availability).toBe('available');
|
|
85
|
+
});
|
|
86
|
+
it.each(['execution', 'qa', 'retro', 'history'])('isolates malformed %s findings', (key) => {
|
|
87
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => ({ [key]: [{ id: '', label: 'Profile', state: 'ok' }] }) } });
|
|
88
|
+
expect(snapshot.sections.find((section) => section.id === key)?.availability).toBe('unavailable');
|
|
89
|
+
});
|
|
90
|
+
it('renders exact remediation only for a canonical optional source failure', () => {
|
|
91
|
+
const knownFailure = Object.assign(new Error('sensors unavailable'), { findingId: 'project.sensors.unavailable', remediationVerified: true });
|
|
92
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
93
|
+
cwd: process.cwd(), now: fixedNow,
|
|
94
|
+
adapters: { machine: () => ({ findings: [] }), project: () => { throw knownFailure; }, plans: () => [], execution: () => undefined },
|
|
95
|
+
});
|
|
96
|
+
expect(snapshot.sections.find((section) => section.id === 'project')?.items).toEqual([
|
|
97
|
+
expect.objectContaining({ id: 'project.sensors.unavailable', state: 'unavailable', remediation: 'awm sensors status' }),
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
it.each([
|
|
101
|
+
['plans', 'planning.source.unavailable', 'planning', 'awm preflight'],
|
|
102
|
+
['execution', 'execution.source.unavailable', 'execution', 'awm sensors status'],
|
|
103
|
+
])('renders a known %s failure in its owner section', (adapter, findingId, sectionId, remediation) => {
|
|
104
|
+
const failure = Object.assign(new Error('unavailable'), { findingId, remediationVerified: true });
|
|
105
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
106
|
+
cwd: process.cwd(), now: fixedNow,
|
|
107
|
+
adapters: {
|
|
108
|
+
machine: () => ({ findings: [] }), project: () => ({ findings: [] }),
|
|
109
|
+
plans: () => { if (adapter === 'plans')
|
|
110
|
+
throw failure; return []; },
|
|
111
|
+
execution: () => { if (adapter === 'execution')
|
|
112
|
+
throw failure; return undefined; },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
expect(snapshot.sections.find((section) => section.id === sectionId)?.items[0]).toEqual(expect.objectContaining({ id: findingId, remediation }));
|
|
116
|
+
});
|
|
117
|
+
it('sorts findings by stable canonical id', () => {
|
|
118
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
119
|
+
cwd: '/definitely-not-a-project', now: fixedNow,
|
|
120
|
+
adapters: { machine: () => ({ findings: [
|
|
121
|
+
{ id: 'machine.registries.stale', label: 'Registries', state: 'attention' },
|
|
122
|
+
{ id: 'machine.preferences.missing', label: 'Preferences', state: 'missing' },
|
|
123
|
+
] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn() },
|
|
124
|
+
});
|
|
125
|
+
expect(snapshot.sections[0].items.map((item) => item.id)).toEqual(['machine.preferences.missing', 'machine.registries.stale']);
|
|
126
|
+
});
|
|
127
|
+
it.each(['blocked', 'active', 'executed', 'retro_pending', 'qa_pending', 'legacy_unverifiable'])('integrates lifecycle state %s into plan detail', (expected) => {
|
|
128
|
+
const lifecycle = expected === 'blocked' ? { journal: { state: 'blocked' }, markers: { qaComplete: false, retroComplete: false }, tasks: { total: 1, completed: 0 } }
|
|
129
|
+
: expected === 'active' ? { journal: { state: 'active' }, markers: { qaComplete: false, retroComplete: false }, tasks: { total: 1, completed: 0 } }
|
|
130
|
+
: expected === 'executed' ? { markers: { qaComplete: true, retroComplete: true }, tasks: { total: 1, completed: 1 } }
|
|
131
|
+
: expected === 'retro_pending' ? { markers: { qaComplete: true, retroComplete: false }, tasks: { total: 1, completed: 1 } }
|
|
132
|
+
: expected === 'qa_pending' ? { markers: { qaComplete: false, retroComplete: false }, tasks: { total: 1, completed: 1 } }
|
|
133
|
+
: { markers: { qaComplete: false, retroComplete: false }, tasks: { total: 0, completed: 0 } };
|
|
134
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [{ id: 'plan.lifecycle', label: 'Profile', state: 'ok', lifecycle }], execution: () => undefined } });
|
|
135
|
+
expect(snapshot.sections.find((section) => section.id === 'planning')?.items[0].detail).toBe(expected);
|
|
136
|
+
});
|
|
137
|
+
it('degrades a machine-only dashboard for actionable machine findings', () => {
|
|
138
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({
|
|
139
|
+
cwd: '/definitely-not-a-project', now: fixedNow,
|
|
140
|
+
adapters: { machine: () => ({ findings: [{ id: 'machine.preferences.missing', label: 'Preferences', state: 'missing' }] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn() },
|
|
141
|
+
});
|
|
142
|
+
expect(snapshot.sections.map((section) => section.id)).toEqual(['machine']);
|
|
143
|
+
expect(snapshot.overall).toBe('degraded');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe('sanitizeDashboardSource', () => {
|
|
147
|
+
it('removes hostile paths and secrets before rendering', () => {
|
|
148
|
+
const safe = (0, sanitize_1.sanitizeDashboardSource)({ path: '/var/lib/private', token: 'ghp_secret', username: 'alice', output: '<script>alert(1)</script>', rawOutput: 'sk-live-secret', detail: 'log:(cwd=/tmp/run/output) unc=(\\\\server\\share\\secret)(MODE=production)', label: 'alice workstation' });
|
|
149
|
+
expect(JSON.stringify(safe)).not.toMatch(/alice|ghp_|script|\/var\/|\/tmp\/|sk-live|alert|server|share|MODE=production/i);
|
|
150
|
+
});
|
|
151
|
+
it('replaces dynamic finding identifiers with opaque safe identifiers', () => {
|
|
152
|
+
const safe = (0, sanitize_1.sanitizeDashboardSource)({ findings: [
|
|
153
|
+
{ id: 'alice@example.com', label: 'Preferences', state: 'missing' },
|
|
154
|
+
{ id: '192.0.2.44/repository-private', label: 'Profile', state: 'missing' },
|
|
155
|
+
{ id: 'machine.preferences.missing', label: 'Preferences', state: 'missing' },
|
|
156
|
+
] });
|
|
157
|
+
const serialized = JSON.stringify(safe);
|
|
158
|
+
expect(serialized).not.toMatch(/alice@example|192\.0\.2\.44|repository-private/i);
|
|
159
|
+
expect(serialized).toContain('machine.preferences.missing');
|
|
160
|
+
expect(serialized).toMatch(/item-[a-f0-9]{16}/);
|
|
161
|
+
});
|
|
162
|
+
it('rejects invalid item states explicitly', () => {
|
|
163
|
+
expect(() => (0, sanitize_1.sanitizeDashboardSource)({ state: 'invented' })).toThrow(/state/i);
|
|
164
|
+
});
|
|
165
|
+
it('omits raw dynamic command and error details', () => {
|
|
166
|
+
const safe = (0, sanitize_1.sanitizeDashboardSource)({ findings: [{ id: 'machine.preferences.missing', label: 'Preferences', state: 'missing', detail: 'Error: ghp_secret at /tmp/private; TOKEN=value' }] });
|
|
167
|
+
expect(JSON.stringify(safe)).not.toMatch(/Error|ghp_|\/tmp|TOKEN=/i);
|
|
168
|
+
expect(JSON.stringify(safe)).not.toContain('detail');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
test('exports canonical remediation commands', () => {
|
|
172
|
+
expect(collect_1.REMEDIATION_BY_FINDING_ID['machine.preferences.missing']).toBe('awm init');
|
|
173
|
+
});
|