claude-code-session-manager 0.84.0 → 0.86.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/assets/{AgentLibrary-CCVIpoSz.js → AgentLibrary-DTFL7y8G.js} +1 -1
- package/dist/assets/{DataModel-BltREYde.js → DataModel-Q4jhl24R.js} +1 -1
- package/dist/assets/{History-BZxFkOp6.js → History-Cj2FejEo.js} +1 -1
- package/dist/assets/{Hooks-Bznlfaaa.js → Hooks-CaelQI6t.js} +1 -1
- package/dist/assets/{HostBilko-DUG5YHA_.js → HostBilko--v7cMR8I.js} +1 -1
- package/dist/assets/{Library-Bi2Fn3w9.js → Library-DgI9oCCZ.js} +1 -1
- package/dist/assets/{ListDetail-4VBvXKrz.js → ListDetail-DYUZN-x-.js} +1 -1
- package/dist/assets/{MarkdownEditor-D2ft_v5j.js → MarkdownEditor-DCIubYWf.js} +1 -1
- package/dist/assets/{McpServers-FDekKkyE.js → McpServers-ypCYURh3.js} +1 -1
- package/dist/assets/{Memory-Dkl80uj_.js → Memory-C2qYp-3M.js} +1 -1
- package/dist/assets/{Panel-LurG5VfD.js → Panel-Cj2kw-Zv.js} +1 -1
- package/dist/assets/{Permissions-D2wHBCFA.js → Permissions-BiYZNGYW.js} +1 -1
- package/dist/assets/{Plugins-CTw_wwbI.js → Plugins-C1Vj8_dU.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CW6HNUv6.js → ProvenanceBadge-DczPNM5U.js} +1 -1
- package/dist/assets/{SaveBar-alDHG6cP.js → SaveBar-Cd_7U6Gb.js} +1 -1
- package/dist/assets/{Scheduler-DxiPcaiW.js → Scheduler-DcLBiJBq.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DzFXUKLZ.js → ScopeSwitcher-DVSyI44-.js} +1 -1
- package/dist/assets/{Settings-B6H3v2am.js → Settings-Cv-pRyms.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-B8EZalpN.js → SkillReferenceGraph-CUv1_Q2c.js} +1 -1
- package/dist/assets/{Skills-DwuHuA08.js → Skills-C_YHkAy-.js} +1 -1
- package/dist/assets/{SystemPrompt-CMMqpGYn.js → SystemPrompt-B8R7T9xn.js} +1 -1
- package/dist/assets/{TagLibrary-C2N5C1n9.js → TagLibrary-dj9YHWyy.js} +1 -1
- package/dist/assets/{TiptapBody-btlID-dQ.js → TiptapBody-DnSBUjHE.js} +1 -1
- package/dist/assets/{Toggle-BGOn3DZj.js → Toggle-CjV_BJn6.js} +1 -1
- package/dist/assets/{index-mnjNDpb1.css → index-CDo9xBR9.css} +1 -1
- package/dist/assets/{index-QLRf0epp.js → index-CXFQIPhO.js} +3 -3
- package/dist/assets/{settingsSchema-DA3N2Up3.js → settingsSchema-BJVciriw.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
- package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
- package/src/main/__tests__/machineProfile.test.cjs +19 -1
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +6 -0
- package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +135 -0
- package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +144 -0
- package/src/main/__tests__/telemetryClient.test.cjs +250 -5
- package/src/main/__tests__/telemetryContract.test.cjs +74 -27
- package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
- package/src/main/health.cjs +75 -2
- package/src/main/index.cjs +2 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +6 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +6 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +22 -13
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +6 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +6 -0
- package/src/main/lib/loadGate.cjs +23 -1
- package/src/main/lib/machineProfile.cjs +15 -0
- package/src/main/lib/queueStore.cjs +8 -2
- package/src/main/lib/schedulerBatch.cjs +12 -1
- package/src/main/lib/schedulerConfig.cjs +13 -0
- package/src/main/lib/telemetryBoot.cjs +18 -10
- package/src/main/lib/telemetryClient.cjs +162 -6
- package/src/main/lib/telemetrySettings.cjs +20 -3
- package/src/main/pty.cjs +9 -0
- package/src/main/scheduler.cjs +600 -66
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-quarantine-autoresolve.test.cjs
|
|
3
|
+
*
|
|
4
|
+
* Covers the bounded automatic exit for a quarantined row that never
|
|
5
|
+
* received a `createdVia` provenance stamp (this PRD): the pure selector
|
|
6
|
+
* `selectQuarantineAutoResolveTargets`, the async applier
|
|
7
|
+
* `autoResolveQuarantine` (which re-reads the PRD file's frontmatter fresh
|
|
8
|
+
* from disk immediately before transitioning, so it can never race
|
|
9
|
+
* reconcile()'s own adopt path), and the kill switch
|
|
10
|
+
* `quarantineAutoResolveDisabled`.
|
|
11
|
+
*
|
|
12
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
|
|
13
|
+
* reason as scheduler-failed-autoreset.test.cjs (appendAuditEvent writes
|
|
14
|
+
* under $HOME/.claude/session-manager/audit-log.jsonl).
|
|
15
|
+
*
|
|
16
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const assert = require('node:assert/strict');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'quarantine-autoresolve-test-'));
|
|
27
|
+
process.env.HOME = tmpHome;
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
selectQuarantineAutoResolveTargets,
|
|
31
|
+
autoResolveQuarantine,
|
|
32
|
+
QUARANTINE_RESOLVE_CAP,
|
|
33
|
+
quarantineAutoResolveDisabled,
|
|
34
|
+
} = require('../scheduler.cjs');
|
|
35
|
+
|
|
36
|
+
const MIN_MS = 60_000;
|
|
37
|
+
const THRESHOLD_MS = 60 * MIN_MS;
|
|
38
|
+
|
|
39
|
+
function quarantinedJob(overrides = {}) {
|
|
40
|
+
return {
|
|
41
|
+
slug: 'quarantined-row',
|
|
42
|
+
cwd: '/home/user/project',
|
|
43
|
+
status: 'quarantined',
|
|
44
|
+
statusHistory: [{ to: 'quarantined', at: new Date(Date.now() - 90 * MIN_MS).toISOString() }],
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function registerActiveProject(cwd) {
|
|
50
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
51
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
52
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
53
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeFixtureProject(prefix) {
|
|
57
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
58
|
+
registerActiveProject(cwd);
|
|
59
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'test-epic-1', 'prds');
|
|
60
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
61
|
+
return { cwd, prdsDir };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- selectQuarantineAutoResolveTargets ---
|
|
65
|
+
|
|
66
|
+
test('a fresh quarantined row (under threshold) is not selected', () => {
|
|
67
|
+
const job = quarantinedJob({
|
|
68
|
+
statusHistory: [{ to: 'quarantined', at: new Date(Date.now() - 2 * MIN_MS).toISOString() }],
|
|
69
|
+
});
|
|
70
|
+
assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a non-quarantined status is never selected', () => {
|
|
74
|
+
const job = quarantinedJob({ status: 'failed' });
|
|
75
|
+
assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a quarantined row with no statusHistory entry cannot have its age proven, and is skipped', () => {
|
|
79
|
+
const job = quarantinedJob({ statusHistory: [] });
|
|
80
|
+
assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a quarantined row past threshold, under the cap, is selected', () => {
|
|
84
|
+
const job = quarantinedJob({ quarantineResolveAttempts: 0 });
|
|
85
|
+
const found = selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS);
|
|
86
|
+
assert.equal(found.length, 1);
|
|
87
|
+
assert.equal(found[0].slug, 'quarantined-row');
|
|
88
|
+
assert.ok(found[0].ageMs >= 90 * MIN_MS - 1000);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a quarantined row that already spent its resolve cap is excluded', () => {
|
|
92
|
+
const job = quarantinedJob({ quarantineResolveAttempts: QUARANTINE_RESOLVE_CAP });
|
|
93
|
+
assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('quarantineAutoResolveDisabled reflects SM_QUARANTINE_AUTORESOLVE_DISABLE', () => {
|
|
97
|
+
const saved = process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
|
|
98
|
+
try {
|
|
99
|
+
delete process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
|
|
100
|
+
assert.equal(quarantineAutoResolveDisabled(), false);
|
|
101
|
+
process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE = '1';
|
|
102
|
+
assert.equal(quarantineAutoResolveDisabled(), true);
|
|
103
|
+
} finally {
|
|
104
|
+
if (saved === undefined) delete process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
|
|
105
|
+
else process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE = saved;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- autoResolveQuarantine: the applier ---
|
|
110
|
+
|
|
111
|
+
test('a quarantined row whose PRD file still has no createdVia is auto-resolved to skipped', async () => {
|
|
112
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-quarantine-autoresolve-noprovenance-');
|
|
113
|
+
try {
|
|
114
|
+
fs.writeFileSync(
|
|
115
|
+
path.join(prdsDir, 'quarantined-row.md'),
|
|
116
|
+
`---\ntitle: Unstamped PRD\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nDo the thing.\n`,
|
|
117
|
+
'utf8',
|
|
118
|
+
);
|
|
119
|
+
const job = quarantinedJob({ cwd, quarantineResolveAttempts: 0 });
|
|
120
|
+
const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
|
|
121
|
+
assert.equal(outcome, 'skipped');
|
|
122
|
+
assert.equal(job.status, 'skipped');
|
|
123
|
+
assert.equal(job.quarantineResolveAttempts, 1);
|
|
124
|
+
assert.ok(typeof job.error === 'string' && /createdVia/.test(job.error));
|
|
125
|
+
} finally {
|
|
126
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a quarantined row whose PRD file HAS been stamped with createdVia is left alone — reconcile owns it now', async () => {
|
|
131
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-quarantine-autoresolve-stamped-');
|
|
132
|
+
try {
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(prdsDir, 'quarantined-row.md'),
|
|
135
|
+
`---\ntitle: Stamped PRD\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
|
|
136
|
+
'utf8',
|
|
137
|
+
);
|
|
138
|
+
const job = quarantinedJob({ cwd, quarantineResolveAttempts: 0 });
|
|
139
|
+
const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
|
|
140
|
+
assert.equal(outcome, null);
|
|
141
|
+
assert.equal(job.status, 'quarantined'); // untouched — the adopt path owns the next pass
|
|
142
|
+
assert.equal(job.quarantineResolveAttempts, 0);
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('autoResolveQuarantine race-guards against a row that moved off quarantined', async () => {
|
|
149
|
+
const job = quarantinedJob({ status: 'pending', quarantineResolveAttempts: 0 });
|
|
150
|
+
assert.equal(await autoResolveQuarantine(job, 90 * MIN_MS), null);
|
|
151
|
+
assert.equal(job.status, 'pending'); // untouched
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('autoResolveQuarantine race-guards against a row whose resolve cap is already spent', async () => {
|
|
155
|
+
const job = quarantinedJob({ quarantineResolveAttempts: QUARANTINE_RESOLVE_CAP });
|
|
156
|
+
assert.equal(await autoResolveQuarantine(job, 90 * MIN_MS), null);
|
|
157
|
+
assert.equal(job.status, 'quarantined'); // untouched
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a quarantined row whose PRD file is missing entirely is treated as still lacking provenance and auto-resolved', async () => {
|
|
161
|
+
const job = quarantinedJob({ cwd: '/nonexistent/project/path', quarantineResolveAttempts: 0 });
|
|
162
|
+
const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
|
|
163
|
+
assert.equal(outcome, 'skipped');
|
|
164
|
+
assert.equal(job.status, 'skipped');
|
|
165
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-starve-escalation.test.cjs — the `project_starved` audit event
|
|
3
|
+
* (scheduler.cjs ~line 8888) used to be emit-only: it fired every sweep
|
|
4
|
+
* forever with zero consequence. The 2026-09-12 audit log showed
|
|
5
|
+
* /home/bilko/Projects/Bilko emitting it ~115 times over a 19h starve with
|
|
6
|
+
* nothing acting on it. runStarveEscalationSweep is the bounded, automated
|
|
7
|
+
* consequence: a starve outliving STARVE_ESCALATION_MS gets a DISTINCT
|
|
8
|
+
* 'project_starve_escalated' audit event (once per starve stretch, latched
|
|
9
|
+
* per cwd) plus a toast-channel error via the existing 'schedule:stall'
|
|
10
|
+
* push (already wired to toast.error on the renderer side).
|
|
11
|
+
*
|
|
12
|
+
* Exercises selectStarveEscalations() (pure) directly, and
|
|
13
|
+
* runStarveEscalationSweep() (the side-effecting wrapper) against a real,
|
|
14
|
+
* scratch audit-log.jsonl and a fake attached window — matching
|
|
15
|
+
* queue-starvation-per-project.test.cjs's pattern of setting HOME before
|
|
16
|
+
* require() since every state path is baked from os.homedir() at load.
|
|
17
|
+
*
|
|
18
|
+
* Run: timeout 180 npx vitest run src/main/__tests__/scheduler-starve-escalation.test.cjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
import { test, beforeEach } from 'vitest';
|
|
24
|
+
const assert = require('node:assert/strict');
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'starve-escalation-test-'));
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
selectStarveEscalations,
|
|
33
|
+
runStarveEscalationSweep,
|
|
34
|
+
STARVE_ESCALATION_MS,
|
|
35
|
+
attachWindow,
|
|
36
|
+
} = require('../scheduler.cjs');
|
|
37
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
38
|
+
|
|
39
|
+
const CWD_A = '/home/bilko/Projects/Bilko';
|
|
40
|
+
const CWD_B = '/home/bilko/Projects/session-manager';
|
|
41
|
+
|
|
42
|
+
function readAuditKind(kind) {
|
|
43
|
+
let lines;
|
|
44
|
+
try {
|
|
45
|
+
lines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').split('\n').filter(Boolean);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return lines.map((l) => JSON.parse(l)).filter((r) => r.kind === kind);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fakeWindow() {
|
|
53
|
+
const sent = [];
|
|
54
|
+
return {
|
|
55
|
+
sent,
|
|
56
|
+
isDestroyed: () => false,
|
|
57
|
+
webContents: {
|
|
58
|
+
isDestroyed: () => false,
|
|
59
|
+
isCrashed: () => false,
|
|
60
|
+
send: (channel, payload) => sent.push({ channel, payload }),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
// Fresh scratch audit log per test so escalation counts never bleed across
|
|
67
|
+
// tests in this file.
|
|
68
|
+
fs.mkdirSync(path.dirname(AUDIT_LOG_PATH), { recursive: true });
|
|
69
|
+
fs.writeFileSync(AUDIT_LOG_PATH, '');
|
|
70
|
+
attachWindow(null);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('selectStarveEscalations: escalates a row past threshold not already latched', () => {
|
|
74
|
+
const starved = [{ cwd: CWD_A, pendingCount: 3, oldestPendingSlug: 'x', ageMs: STARVE_ESCALATION_MS + 1 }];
|
|
75
|
+
const { toEscalate, toClear } = selectStarveEscalations(starved, new Set(), STARVE_ESCALATION_MS);
|
|
76
|
+
assert.equal(toEscalate.length, 1);
|
|
77
|
+
assert.equal(toEscalate[0].cwd, CWD_A);
|
|
78
|
+
assert.deepEqual(toClear, []);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('selectStarveEscalations: never re-escalates a cwd already latched', () => {
|
|
82
|
+
const starved = [{ cwd: CWD_A, pendingCount: 3, oldestPendingSlug: 'x', ageMs: STARVE_ESCALATION_MS + 60_000 }];
|
|
83
|
+
const { toEscalate } = selectStarveEscalations(starved, new Set([CWD_A]), STARVE_ESCALATION_MS);
|
|
84
|
+
assert.deepEqual(toEscalate, []);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('selectStarveEscalations: a latched cwd no longer starved is returned for latch-clear', () => {
|
|
88
|
+
const { toEscalate, toClear } = selectStarveEscalations([], new Set([CWD_A]), STARVE_ESCALATION_MS);
|
|
89
|
+
assert.deepEqual(toEscalate, []);
|
|
90
|
+
assert.deepEqual(toClear, [CWD_A]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('selectStarveEscalations: below-threshold starve is neither escalated nor cleared', () => {
|
|
94
|
+
const starved = [{ cwd: CWD_A, pendingCount: 1, oldestPendingSlug: 'x', ageMs: STARVE_ESCALATION_MS - 1 }];
|
|
95
|
+
const { toEscalate, toClear } = selectStarveEscalations(starved, new Set(), STARVE_ESCALATION_MS);
|
|
96
|
+
assert.deepEqual(toEscalate, []);
|
|
97
|
+
assert.deepEqual(toClear, []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('runStarveEscalationSweep: emits project_starve_escalated exactly once per starve stretch, plus a toast', () => {
|
|
101
|
+
const win = fakeWindow();
|
|
102
|
+
attachWindow(win);
|
|
103
|
+
const sp = { cwd: CWD_A, pendingCount: 4, oldestPendingSlug: 'oldest-a', ageMs: STARVE_ESCALATION_MS + 60_000 };
|
|
104
|
+
|
|
105
|
+
runStarveEscalationSweep([sp]);
|
|
106
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 1);
|
|
107
|
+
assert.equal(win.sent.length, 1);
|
|
108
|
+
assert.equal(win.sent[0].channel, 'schedule:stall');
|
|
109
|
+
assert.match(win.sent[0].payload.message, /Bilko/);
|
|
110
|
+
|
|
111
|
+
const record = readAuditKind('project_starve_escalated')[0];
|
|
112
|
+
assert.equal(record.cwd, CWD_A);
|
|
113
|
+
assert.equal(record.pendingCount, 4);
|
|
114
|
+
assert.equal(record.oldestPendingSlug, 'oldest-a');
|
|
115
|
+
assert.equal(record.ageMs, sp.ageMs);
|
|
116
|
+
assert.ok(
|
|
117
|
+
['slots-exhausted', 'memory-deferred', 'load-deferred', 'held', 'unknown'].includes(record.holdReason),
|
|
118
|
+
`unexpected holdReason: ${record.holdReason}`,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Same still-starved stretch on the NEXT sweep must not escalate again.
|
|
122
|
+
runStarveEscalationSweep([sp]);
|
|
123
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 1);
|
|
124
|
+
assert.equal(win.sent.length, 1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('runStarveEscalationSweep: the latch resets once the starve clears, so a later stretch escalates again', () => {
|
|
128
|
+
const win = fakeWindow();
|
|
129
|
+
attachWindow(win);
|
|
130
|
+
const sp = { cwd: CWD_B, pendingCount: 2, oldestPendingSlug: 'oldest-b', ageMs: STARVE_ESCALATION_MS + 1 };
|
|
131
|
+
|
|
132
|
+
runStarveEscalationSweep([sp]);
|
|
133
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 1);
|
|
134
|
+
|
|
135
|
+
// Starve clears: this cwd is no longer reported by findStarvedProjects at all.
|
|
136
|
+
runStarveEscalationSweep([]);
|
|
137
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 1); // no new escalation on a clear pass
|
|
138
|
+
|
|
139
|
+
// A NEW starve stretch on the same cwd must escalate again — the latch
|
|
140
|
+
// must not have been permanently burned by the first stretch.
|
|
141
|
+
runStarveEscalationSweep([sp]);
|
|
142
|
+
assert.equal(readAuditKind('project_starve_escalated').length, 2);
|
|
143
|
+
assert.equal(win.sent.length, 2);
|
|
144
|
+
});
|
|
@@ -17,12 +17,14 @@ let originalHome;
|
|
|
17
17
|
let originalSmTelemetry;
|
|
18
18
|
let originalSmTelemetryEndpoint;
|
|
19
19
|
let originalSmBeaconKey;
|
|
20
|
+
let originalSmTelemetrySpool;
|
|
20
21
|
|
|
21
22
|
beforeEach(() => {
|
|
22
23
|
originalHome = process.env.HOME;
|
|
23
24
|
originalSmTelemetry = process.env.SM_TELEMETRY;
|
|
24
25
|
originalSmTelemetryEndpoint = process.env.SM_TELEMETRY_ENDPOINT;
|
|
25
26
|
originalSmBeaconKey = process.env.SM_BEACON_KEY;
|
|
27
|
+
originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
afterEach(async () => {
|
|
@@ -30,6 +32,7 @@ afterEach(async () => {
|
|
|
30
32
|
if (originalSmTelemetry === undefined) delete process.env.SM_TELEMETRY; else process.env.SM_TELEMETRY = originalSmTelemetry;
|
|
31
33
|
if (originalSmTelemetryEndpoint === undefined) delete process.env.SM_TELEMETRY_ENDPOINT; else process.env.SM_TELEMETRY_ENDPOINT = originalSmTelemetryEndpoint;
|
|
32
34
|
if (originalSmBeaconKey === undefined) delete process.env.SM_BEACON_KEY; else process.env.SM_BEACON_KEY = originalSmBeaconKey;
|
|
35
|
+
if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
|
|
33
36
|
vi.useRealTimers();
|
|
34
37
|
vi.unstubAllGlobals();
|
|
35
38
|
while (tmpDirs.length) {
|
|
@@ -51,6 +54,13 @@ function fakeProfile(overrides = {}) {
|
|
|
51
54
|
arch: 'x64',
|
|
52
55
|
machineDigest: 'deadbeefcafe',
|
|
53
56
|
installChannel: 'dev',
|
|
57
|
+
osRelease: '6.1.0',
|
|
58
|
+
cpuCount: 8,
|
|
59
|
+
totalMemMb: 16384,
|
|
60
|
+
nodeVersion: '20.11.0',
|
|
61
|
+
electronVersion: '33.0.0',
|
|
62
|
+
locale: 'en-US',
|
|
63
|
+
timezoneOffsetMinutes: 420,
|
|
54
64
|
...overrides,
|
|
55
65
|
};
|
|
56
66
|
}
|
|
@@ -65,6 +75,11 @@ function fakeProfile(overrides = {}) {
|
|
|
65
75
|
*/
|
|
66
76
|
function freshClient(home, profileOverrides) {
|
|
67
77
|
process.env.HOME = home;
|
|
78
|
+
// Explicit opt-in: this suite deliberately exercises telemetryClient's real
|
|
79
|
+
// queue/sent-file I/O, so it must override the test-environment no-op guard
|
|
80
|
+
// by pointing the spool at this test's own isolated tmp dir (never a real
|
|
81
|
+
// user's ~/.config/session-manager).
|
|
82
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
|
|
68
83
|
for (const p of ['../lib/telemetryClient.cjs', '../config.cjs', '../lib/telemetrySettings.cjs', '../lib/machineProfile.cjs']) {
|
|
69
84
|
const resolved = require.resolve(p);
|
|
70
85
|
delete require.cache[resolved];
|
|
@@ -110,7 +125,7 @@ async function readQueueLines(client) {
|
|
|
110
125
|
test('exports the full contract', async () => {
|
|
111
126
|
const home = await mkHome();
|
|
112
127
|
const client = freshClient(home);
|
|
113
|
-
for (const fn of ['track', 'logLine', 'reportError', 'flush', 'shutdown', 'status', 'recentRecords', 'isPending']) {
|
|
128
|
+
for (const fn of ['track', 'logLine', 'reportError', 'reportInstall', 'flush', 'shutdown', 'status', 'recentRecords', 'isPending']) {
|
|
114
129
|
expect(typeof client[fn]).toBe('function');
|
|
115
130
|
}
|
|
116
131
|
client.shutdown();
|
|
@@ -130,10 +145,31 @@ test('ingress functions never throw on hostile inputs', async () => {
|
|
|
130
145
|
await expect(client.track(bad, bad)).resolves.toBeDefined();
|
|
131
146
|
await expect(client.logLine(bad)).resolves.toBeDefined();
|
|
132
147
|
await expect(client.reportError(bad)).resolves.toBeDefined();
|
|
148
|
+
await expect(client.reportInstall(bad)).resolves.toBeDefined();
|
|
133
149
|
}
|
|
134
150
|
client.shutdown();
|
|
135
151
|
});
|
|
136
152
|
|
|
153
|
+
// ─── reportInstall failure visibility ──────────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('reportInstall logs a warn line naming the thrown error instead of swallowing it silently', async () => {
|
|
156
|
+
const home = await mkHome();
|
|
157
|
+
const client = freshClient(home);
|
|
158
|
+
client._setMachineProfileBuilder(() => { throw new Error('boom-profile'); });
|
|
159
|
+
const warnLines = [];
|
|
160
|
+
client._setLogger((payload) => warnLines.push(payload));
|
|
161
|
+
|
|
162
|
+
const result = await client.reportInstall(fakeProfile());
|
|
163
|
+
|
|
164
|
+
expect(result).toEqual({ accepted: false, reason: 'error' });
|
|
165
|
+
expect(warnLines).toHaveLength(1);
|
|
166
|
+
expect(warnLines[0].scope).toBe('telemetry');
|
|
167
|
+
expect(warnLines[0].level).toBe('warn');
|
|
168
|
+
expect(warnLines[0].message).toBe('reportInstall failed');
|
|
169
|
+
expect(warnLines[0].meta.error).toContain('boom-profile');
|
|
170
|
+
client.shutdown();
|
|
171
|
+
});
|
|
172
|
+
|
|
137
173
|
// ─── disabled at ingress ───────────────────────────────────────────────
|
|
138
174
|
|
|
139
175
|
test('when telemetry is disabled, ingress drops at the point of entry', async () => {
|
|
@@ -146,6 +182,7 @@ test('when telemetry is disabled, ingress drops at the point of entry', async ()
|
|
|
146
182
|
await client.track('evt', { a: 1 });
|
|
147
183
|
await client.logLine({ level: 'info', msg: 'hi' });
|
|
148
184
|
await client.reportError({ name: 'E', msg: 'boom', stack: 'at x' });
|
|
185
|
+
await client.reportInstall(fakeProfile());
|
|
149
186
|
|
|
150
187
|
expect(client.status().pendingCount).toBe(0);
|
|
151
188
|
expect(await readQueueLines(client)).toEqual([]);
|
|
@@ -252,6 +289,55 @@ test('attribution fields are exempt from redaction/clamping and arrive byte-iden
|
|
|
252
289
|
client.shutdown();
|
|
253
290
|
});
|
|
254
291
|
|
|
292
|
+
// ─── env discriminator (wire-level, all four channels) ─────────────────
|
|
293
|
+
|
|
294
|
+
test('every channel carries env:"dev" when SM_DEV drives installChannel, and it survives the queue round-trip through flush', async () => {
|
|
295
|
+
const home = await mkHome();
|
|
296
|
+
const client = freshClient(home, { installChannel: 'dev' });
|
|
297
|
+
const fetchFn = fetchStub(async () => okResponse());
|
|
298
|
+
client._setFetchImpl(fetchFn);
|
|
299
|
+
|
|
300
|
+
await client.track('evt', { a: 1 });
|
|
301
|
+
await client.logLine({ level: 'info', msg: 'hi' });
|
|
302
|
+
await client.reportError({ name: 'E', msg: 'boom', stack: 'at x' });
|
|
303
|
+
await client.reportInstall(fakeProfile({ installChannel: 'dev' }));
|
|
304
|
+
|
|
305
|
+
// Present the instant it's appended to telemetry-queue.jsonl, before any flush.
|
|
306
|
+
const queued = await readQueueLines(client);
|
|
307
|
+
expect(queued).toHaveLength(4);
|
|
308
|
+
for (const rec of queued) expect(rec.wire.env).toBe('dev');
|
|
309
|
+
|
|
310
|
+
// Still present in the exact body flush() POSTs.
|
|
311
|
+
await client.flush('manual');
|
|
312
|
+
expect(fetchFn.calls.length).toBeGreaterThan(0);
|
|
313
|
+
for (const call of fetchFn.calls) {
|
|
314
|
+
const bodies = Array.isArray(call.body.batch) ? call.body.batch : [call.body];
|
|
315
|
+
for (const wire of bodies) expect(wire.env).toBe('dev');
|
|
316
|
+
}
|
|
317
|
+
client.shutdown();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('every channel carries env:"prod" for a non-dev installChannel', async () => {
|
|
321
|
+
const home = await mkHome();
|
|
322
|
+
const client = freshClient(home, { installChannel: 'npx' });
|
|
323
|
+
|
|
324
|
+
await client.track('evt', { a: 1 });
|
|
325
|
+
await client.logLine({ level: 'info', msg: 'hi' });
|
|
326
|
+
await client.reportError({ name: 'E', msg: 'boom', stack: 'at x' });
|
|
327
|
+
await client.reportInstall(fakeProfile({ installChannel: 'npx' }));
|
|
328
|
+
|
|
329
|
+
const queued = await readQueueLines(client);
|
|
330
|
+
expect(queued).toHaveLength(4);
|
|
331
|
+
for (const rec of queued) expect(rec.wire.env).toBe('prod');
|
|
332
|
+
client.shutdown();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('env resolves to "test" under a real test-runner environment, independent of installChannel', () => {
|
|
336
|
+
const machineProfile = require('../lib/machineProfile.cjs');
|
|
337
|
+
expect(machineProfile.resolveEnv({ isTestRunner: true, installChannel: 'dev' })).toBe('test');
|
|
338
|
+
expect(machineProfile.resolveEnv({ isTestRunner: true, installChannel: 'npx' })).toBe('test');
|
|
339
|
+
});
|
|
340
|
+
|
|
255
341
|
// ─── queue is the accumulator, survives restart ────────────────────────
|
|
256
342
|
|
|
257
343
|
test('a record survives a simulated process restart and is still pending', async () => {
|
|
@@ -504,6 +590,7 @@ test('wire field names match server/routes/telemetry.ts exactly', async () => {
|
|
|
504
590
|
await client.track('evt-name', { a: 1 });
|
|
505
591
|
await client.logLine({ level: 'warn', msg: 'm', fields: { b: 2 } });
|
|
506
592
|
await client.reportError({ name: 'E', msg: 'm', stack: 'Error: m\n at f (a.js:1:1)', context: { c: 3 } });
|
|
593
|
+
await client.reportInstall(fakeProfile());
|
|
507
594
|
|
|
508
595
|
const fetchFn = fetchStub(async () => okResponse());
|
|
509
596
|
client._setFetchImpl(fetchFn);
|
|
@@ -512,10 +599,16 @@ test('wire field names match server/routes/telemetry.ts exactly', async () => {
|
|
|
512
599
|
const eventCall = fetchFn.calls.find((c) => c.url.endsWith('/event'));
|
|
513
600
|
const logCall = fetchFn.calls.find((c) => c.url.endsWith('/log'));
|
|
514
601
|
const errorCall = fetchFn.calls.find((c) => c.url.endsWith('/error'));
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
expect(Object.keys(
|
|
518
|
-
expect(Object.keys(
|
|
602
|
+
const installCall = fetchFn.calls.find((c) => c.url.endsWith('/install'));
|
|
603
|
+
|
|
604
|
+
expect(Object.keys(eventCall.body.batch[0]).sort()).toEqual(['app', 'env', 'name', 'props', 'session_id', 'visitor_id'].sort());
|
|
605
|
+
expect(Object.keys(logCall.body.batch[0]).sort()).toEqual(['app', 'env', 'version', 'level', 'msg', 'visitor_id', 'session_id', 'fields', 'ts'].sort());
|
|
606
|
+
expect(Object.keys(errorCall.body.batch[0]).sort()).toEqual(['app', 'env', 'version', 'name', 'msg', 'stack', 'url', 'ua', 'visitor_id', 'session_id', 'context', 'ts'].sort());
|
|
607
|
+
expect(Object.keys(installCall.body).sort()).toEqual([
|
|
608
|
+
'app', 'app_version', 'arch', 'cpu_count', 'electron_version', 'env',
|
|
609
|
+
'install_channel', 'install_id', 'locale', 'node_version',
|
|
610
|
+
'os_release', 'platform', 'timezone', 'total_mem_mb',
|
|
611
|
+
].sort());
|
|
519
612
|
client.shutdown();
|
|
520
613
|
});
|
|
521
614
|
|
|
@@ -778,6 +871,31 @@ test('status() reports a network error (fetch throw / status 0) with a plain-lan
|
|
|
778
871
|
client.shutdown();
|
|
779
872
|
});
|
|
780
873
|
|
|
874
|
+
// ─── test-environment guard (poisoned-spool prevention) ─────────────────
|
|
875
|
+
|
|
876
|
+
test('appendRecord() is a no-op under a test runner unless SM_TELEMETRY_SPOOL is explicitly set', async () => {
|
|
877
|
+
const home = await mkHome();
|
|
878
|
+
process.env.HOME = home;
|
|
879
|
+
delete process.env.SM_TELEMETRY_SPOOL;
|
|
880
|
+
for (const p of ['../lib/telemetryClient.cjs', '../config.cjs', '../lib/telemetrySettings.cjs', '../lib/machineProfile.cjs']) {
|
|
881
|
+
delete require.cache[require.resolve(p)];
|
|
882
|
+
}
|
|
883
|
+
const client = require('../lib/telemetryClient.cjs');
|
|
884
|
+
client._setMachineProfileBuilder(async () => fakeProfile());
|
|
885
|
+
|
|
886
|
+
expect(process.env.VITEST).toBeTruthy();
|
|
887
|
+
const trackResult = await client.track('evt', { a: 1 });
|
|
888
|
+
const logResult = await client.logLine({ level: 'info', msg: 'hi' });
|
|
889
|
+
const errorResult = await client.reportError({ name: 'E', msg: 'boom', stack: 'at x' });
|
|
890
|
+
|
|
891
|
+
expect(trackResult).toEqual({ accepted: false, reason: 'test-environment', recordId: expect.any(String) });
|
|
892
|
+
expect(logResult.accepted).toBe(false);
|
|
893
|
+
expect(errorResult.accepted).toBe(false);
|
|
894
|
+
expect(client.status().pendingCount).toBe(0);
|
|
895
|
+
await expect(fsp.access(client.queuePath())).rejects.toThrow();
|
|
896
|
+
client.shutdown();
|
|
897
|
+
});
|
|
898
|
+
|
|
781
899
|
// ─── clearQueue (opt-out side effect, PRD 1142) ──────────────────────────
|
|
782
900
|
|
|
783
901
|
test('clearQueue() empties the in-memory queue and truncates the on-disk queue file', async () => {
|
|
@@ -794,6 +912,133 @@ test('clearQueue() empties the in-memory queue and truncates the on-disk queue f
|
|
|
794
912
|
client.shutdown();
|
|
795
913
|
});
|
|
796
914
|
|
|
915
|
+
// ─── install channel (PRD: bilko.run app_installs) ──────────────────────
|
|
916
|
+
|
|
917
|
+
test('reportInstall() maps buildMachineProfile()\'s camelCase onto the exact snake_case key set app_installs expects', async () => {
|
|
918
|
+
const home = await mkHome();
|
|
919
|
+
const client = freshClient(home);
|
|
920
|
+
const profile = fakeProfile();
|
|
921
|
+
await client.reportInstall(profile);
|
|
922
|
+
const [rec] = await readQueueLines(client);
|
|
923
|
+
|
|
924
|
+
expect(rec.channel).toBe('install');
|
|
925
|
+
expect(Object.keys(rec.wire).sort()).toEqual([
|
|
926
|
+
'app', 'app_version', 'arch', 'cpu_count', 'electron_version', 'env',
|
|
927
|
+
'install_channel', 'install_id', 'locale', 'node_version',
|
|
928
|
+
'os_release', 'platform', 'timezone', 'total_mem_mb',
|
|
929
|
+
].sort());
|
|
930
|
+
expect(rec.wire.app).toBe('session-manager');
|
|
931
|
+
expect(rec.wire.app_version).toBe(profile.appVersion);
|
|
932
|
+
expect(rec.wire.platform).toBe(profile.platform);
|
|
933
|
+
expect(rec.wire.os_release).toBe(profile.osRelease);
|
|
934
|
+
expect(rec.wire.arch).toBe(profile.arch);
|
|
935
|
+
expect(rec.wire.cpu_count).toBe(profile.cpuCount);
|
|
936
|
+
expect(rec.wire.total_mem_mb).toBe(profile.totalMemMb);
|
|
937
|
+
expect(rec.wire.node_version).toBe(profile.nodeVersion);
|
|
938
|
+
expect(rec.wire.electron_version).toBe(profile.electronVersion);
|
|
939
|
+
expect(rec.wire.install_channel).toBe(profile.installChannel);
|
|
940
|
+
expect(rec.wire.locale).toBe(profile.locale);
|
|
941
|
+
expect(rec.wire.timezone).toBe(String(profile.timezoneOffsetMinutes));
|
|
942
|
+
expect(typeof rec.wire.install_id).toBe('string');
|
|
943
|
+
expect(rec.wire.install_id).toBeTruthy();
|
|
944
|
+
client.shutdown();
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test('the install channel POSTs a flat body, never a {batch:[...]} envelope', async () => {
|
|
948
|
+
const home = await mkHome();
|
|
949
|
+
const client = freshClient(home);
|
|
950
|
+
await client.reportInstall(fakeProfile());
|
|
951
|
+
const fetchFn = fetchStub(async () => okResponse());
|
|
952
|
+
client._setFetchImpl(fetchFn);
|
|
953
|
+
const result = await client.flush('manual');
|
|
954
|
+
|
|
955
|
+
expect(result.sent.length).toBe(1);
|
|
956
|
+
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
957
|
+
expect(fetchFn.calls[0].url.endsWith('/api/telemetry/install')).toBe(true);
|
|
958
|
+
expect(fetchFn.calls[0].body.batch).toBeUndefined();
|
|
959
|
+
expect(fetchFn.calls[0].body.install_id).toBeTruthy();
|
|
960
|
+
client.shutdown();
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test('a 400 on the install channel disables the client like any other malformed-contract 4xx', async () => {
|
|
964
|
+
const home = await mkHome();
|
|
965
|
+
const client = freshClient(home);
|
|
966
|
+
await client.reportInstall(fakeProfile());
|
|
967
|
+
client._setFetchImpl(fetchStub(async () => statusResponse(400)));
|
|
968
|
+
await client.flush('manual');
|
|
969
|
+
const st = client.status();
|
|
970
|
+
expect(st.disabledForProcess).toBe(true);
|
|
971
|
+
expect(st.pendingCount).toBe(1);
|
|
972
|
+
client.shutdown();
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test('a 429 on the install channel backs off rather than disabling, same as event/log/error', async () => {
|
|
976
|
+
const home = await mkHome();
|
|
977
|
+
const client = freshClient(home);
|
|
978
|
+
await client.reportInstall(fakeProfile());
|
|
979
|
+
client._setFetchImpl(fetchStub(async () => statusResponse(429)));
|
|
980
|
+
await client.flush('manual');
|
|
981
|
+
const st = client.status();
|
|
982
|
+
expect(st.disabledForProcess).toBe(false);
|
|
983
|
+
expect(st.backoffUntil).toBeGreaterThan(Date.now() - 1);
|
|
984
|
+
expect(st.pendingCount).toBe(1);
|
|
985
|
+
client.shutdown();
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test('a stub server bound to 127.0.0.1 receives exactly one well-formed install POST during a simulated boot', async () => {
|
|
989
|
+
const http = require('node:http');
|
|
990
|
+
const home = await mkHome();
|
|
991
|
+
let received = null;
|
|
992
|
+
let requestCount = 0;
|
|
993
|
+
const server = http.createServer((req, res) => {
|
|
994
|
+
if (req.url === '/api/telemetry/install') {
|
|
995
|
+
requestCount += 1;
|
|
996
|
+
let raw = '';
|
|
997
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
998
|
+
req.on('end', () => {
|
|
999
|
+
received = JSON.parse(raw);
|
|
1000
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1001
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1002
|
+
});
|
|
1003
|
+
} else {
|
|
1004
|
+
res.writeHead(404);
|
|
1005
|
+
res.end();
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
1009
|
+
const { port } = server.address();
|
|
1010
|
+
process.env.SM_TELEMETRY_ENDPOINT = `http://127.0.0.1:${port}`;
|
|
1011
|
+
|
|
1012
|
+
try {
|
|
1013
|
+
const client = freshClient(home);
|
|
1014
|
+
// No _setFetchImpl — exercises the real global fetch path against the local stub.
|
|
1015
|
+
const profile = fakeProfile();
|
|
1016
|
+
await client.reportInstall(profile);
|
|
1017
|
+
await client.flush('boot');
|
|
1018
|
+
|
|
1019
|
+
expect(requestCount).toBe(1);
|
|
1020
|
+
expect(received).toEqual({
|
|
1021
|
+
install_id: expect.any(String),
|
|
1022
|
+
app: 'session-manager',
|
|
1023
|
+
app_version: profile.appVersion,
|
|
1024
|
+
platform: profile.platform,
|
|
1025
|
+
os_release: profile.osRelease,
|
|
1026
|
+
arch: profile.arch,
|
|
1027
|
+
cpu_count: profile.cpuCount,
|
|
1028
|
+
total_mem_mb: profile.totalMemMb,
|
|
1029
|
+
node_version: profile.nodeVersion,
|
|
1030
|
+
electron_version: profile.electronVersion,
|
|
1031
|
+
install_channel: profile.installChannel,
|
|
1032
|
+
locale: profile.locale,
|
|
1033
|
+
timezone: String(profile.timezoneOffsetMinutes),
|
|
1034
|
+
env: 'dev',
|
|
1035
|
+
});
|
|
1036
|
+
client.shutdown();
|
|
1037
|
+
} finally {
|
|
1038
|
+
await new Promise((resolve) => server.close(resolve));
|
|
1039
|
+
}
|
|
1040
|
+
}, 15000);
|
|
1041
|
+
|
|
797
1042
|
test('clearQueue() never touches telemetry-sent.json', async () => {
|
|
798
1043
|
const home = await mkHome();
|
|
799
1044
|
const client = freshClient(home);
|