leerness 1.36.148 → 1.36.161
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/CHANGELOG.md +59 -0
- package/README.ko.md +4 -1
- package/README.md +9 -5
- package/bin/leerness.js +1663 -271
- package/lib/catalogs.js +1 -1
- package/lib/cursor-session-hook.js +76 -0
- package/lib/diagnostics.js +28 -14
- package/lib/git.js +5 -5
- package/lib/io.js +6 -5
- package/lib/mcp-tools.js +1 -1
- package/lib/npm-process.js +90 -0
- package/lib/pure-utils.js +2 -1
- package/lib/session-presence.js +16 -3
- package/lib/team.js +16 -5
- package/package.json +6 -4
- package/scripts/e2e-command-surface.js +304 -0
- package/scripts/e2e-concurrency.js +141 -0
- package/scripts/e2e-core.js +24 -0
- package/scripts/e2e.js +356 -65
- package/scripts/handoff-readonly-probe.js +403 -0
- package/scripts/lock-probe.js +108 -5
- package/scripts/platform-smoke.js +185 -0
- package/scripts/release-bump-probe.js +137 -0
- package/scripts/release-runtime-probe.js +210 -0
- package/scripts/smoke.js +5 -3
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// T-0099 / UR-0076: historically uncovered command families.
|
|
5
|
+
// Local state commands execute end-to-end in a disposable fixture. Commands that
|
|
6
|
+
// can touch external systems or the desktop are exercised through permission
|
|
7
|
+
// denial or --dry-run, with an explicit proof that no marker was created.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const http = require('http');
|
|
11
|
+
const net = require('net');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const cp = require('child_process');
|
|
15
|
+
|
|
16
|
+
const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
|
|
17
|
+
const TEST_TOKEN_NAME = 'LEERNESS_COMMAND_SURFACE_TOKEN';
|
|
18
|
+
const TEST_TOKEN_VALUE = 'surface-fixture-value-never-persist';
|
|
19
|
+
let total = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
const roots = [];
|
|
23
|
+
const children = new Set();
|
|
24
|
+
|
|
25
|
+
const baseEnv = { ...process.env,
|
|
26
|
+
LEERNESS_OFFLINE: '1',
|
|
27
|
+
LEERNESS_NO_PROMPT: '1',
|
|
28
|
+
LEERNESS_NO_AUTOCHCP: '1',
|
|
29
|
+
LEERNESS_SESSION_ID: 'command-surface-audit',
|
|
30
|
+
[TEST_TOKEN_NAME]: TEST_TOKEN_VALUE,
|
|
31
|
+
};
|
|
32
|
+
delete baseEnv.CODEX_THREAD_ID;
|
|
33
|
+
delete baseEnv.CLAUDE_CODE_SESSION_ID;
|
|
34
|
+
delete baseEnv.LEERNESS_FIXED_INTERVAL;
|
|
35
|
+
|
|
36
|
+
function check(label, condition, detail = '') {
|
|
37
|
+
total++;
|
|
38
|
+
const ok = !!condition;
|
|
39
|
+
process.stdout.write(`${ok ? '✓' : '✗'} ${label}${!ok && detail ? `\n ${detail}` : ''}\n`);
|
|
40
|
+
if (!ok) failed++;
|
|
41
|
+
return ok;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function run(root, args, opts = {}) {
|
|
45
|
+
const argv = [CLI, ...args];
|
|
46
|
+
if (root && !opts.noPath) argv.push('--path', root);
|
|
47
|
+
return cp.spawnSync(process.execPath, argv, {
|
|
48
|
+
cwd: opts.cwd || root || process.cwd(),
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
timeout: opts.timeout || 60000,
|
|
51
|
+
env: { ...baseEnv, ...(opts.env || {}) },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseJson(result) {
|
|
56
|
+
try { return JSON.parse(result.stdout || ''); } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fresh(prefix = 'leerness-command-surface-') {
|
|
60
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
61
|
+
roots.push(root);
|
|
62
|
+
return root;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function git(root, args) {
|
|
66
|
+
const config = path.join(root, '.surface-empty-gitconfig');
|
|
67
|
+
if (!fs.existsSync(config)) fs.writeFileSync(config, '', 'utf8');
|
|
68
|
+
const env = { ...baseEnv, GIT_CONFIG_GLOBAL: config, GIT_CONFIG_SYSTEM: config, GIT_CONFIG_NOSYSTEM: '1' };
|
|
69
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_CONFIG_PARAMETERS']) delete env[key];
|
|
70
|
+
return cp.spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 30000, env });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function freePort() {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const server = net.createServer();
|
|
76
|
+
server.once('error', reject);
|
|
77
|
+
server.listen(0, '127.0.0.1', () => {
|
|
78
|
+
const address = server.address();
|
|
79
|
+
const port = address && address.port;
|
|
80
|
+
server.close(err => err ? reject(err) : resolve(port));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function request(port, method, pathname, body = null) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const payload = body == null ? null : JSON.stringify(body);
|
|
88
|
+
const req = http.request({ hostname: '127.0.0.1', port, method, path: pathname,
|
|
89
|
+
headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {} }, res => {
|
|
90
|
+
let data = '';
|
|
91
|
+
res.setEncoding('utf8');
|
|
92
|
+
res.on('data', chunk => { data += chunk; });
|
|
93
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
94
|
+
});
|
|
95
|
+
req.once('error', reject);
|
|
96
|
+
req.setTimeout(5000, () => req.destroy(new Error('HTTP timeout')));
|
|
97
|
+
if (payload) req.write(payload);
|
|
98
|
+
req.end();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function waitForOutput(child, pattern, timeoutMs = 12000) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
let output = '';
|
|
105
|
+
const timer = setTimeout(() => finish(new Error(`timeout waiting for ${pattern}: ${output.slice(-500)}`)), timeoutMs);
|
|
106
|
+
const onData = chunk => {
|
|
107
|
+
output += chunk.toString();
|
|
108
|
+
if (pattern.test(output)) finish(null, output);
|
|
109
|
+
};
|
|
110
|
+
const onExit = code => finish(new Error(`process exited ${code}: ${output.slice(-500)}`));
|
|
111
|
+
function finish(error, value) {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
child.stdout.off('data', onData);
|
|
114
|
+
child.stderr.off('data', onData);
|
|
115
|
+
child.off('exit', onExit);
|
|
116
|
+
if (error) reject(error); else resolve(value);
|
|
117
|
+
}
|
|
118
|
+
child.stdout.on('data', onData);
|
|
119
|
+
child.stderr.on('data', onData);
|
|
120
|
+
child.once('exit', onExit);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stopChild(child) {
|
|
125
|
+
return new Promise(resolve => {
|
|
126
|
+
if (!child || child.exitCode != null) { resolve(); return; }
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
129
|
+
resolve();
|
|
130
|
+
}, 5000);
|
|
131
|
+
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
132
|
+
try { child.kill('SIGTERM'); } catch { clearTimeout(timer); resolve(); }
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function main() {
|
|
137
|
+
console.log('# leerness command-surface E2E — formerly uncovered command families');
|
|
138
|
+
const root = fresh();
|
|
139
|
+
const init = run(null, ['init', root, '--yes', '--language', 'ko', '--no-stale-check'], { noPath: true, cwd: root, timeout: 90000 });
|
|
140
|
+
check('fixture init succeeds', init.status === 0, init.stderr || init.stdout);
|
|
141
|
+
|
|
142
|
+
// creds list/register/check/refresh: values stay in env and are never persisted.
|
|
143
|
+
const deployScript = path.join(root, 'surface-deploy-command.js');
|
|
144
|
+
const deployMarker = path.join(root, 'surface-deploy-ran.txt');
|
|
145
|
+
fs.writeFileSync(deployScript, "require('fs').writeFileSync('surface-deploy-ran.txt', 'ran\\n');\n", 'utf8');
|
|
146
|
+
let r = run(root, ['creds', 'register', 'surface', '--env-var', TEST_TOKEN_NAME, '--deploy', 'node surface-deploy-command.js']);
|
|
147
|
+
check('creds register stores an env reference', r.status === 0, r.stderr || r.stdout);
|
|
148
|
+
r = run(root, ['creds', 'list', '--json']);
|
|
149
|
+
const credsList = parseJson(r);
|
|
150
|
+
check('creds list returns the registered service', r.status === 0 && credsList?.services?.surface?.envVars?.includes(TEST_TOKEN_NAME), r.stdout || r.stderr);
|
|
151
|
+
const credentialsText = fs.readFileSync(path.join(root, '.harness', 'credentials.local.json'), 'utf8');
|
|
152
|
+
check('creds registry never persists the credential value', !credentialsText.includes(TEST_TOKEN_VALUE));
|
|
153
|
+
r = run(root, ['creds', 'check', 'surface', '--json']);
|
|
154
|
+
const credsCheck = parseJson(r);
|
|
155
|
+
check('creds check confirms the injected env value', r.status === 0 && credsCheck?.ok === true && credsCheck?.services?.surface?.envSet === true, r.stdout || r.stderr);
|
|
156
|
+
r = run(root, ['creds', 'refresh', 'surface']);
|
|
157
|
+
const refreshed = JSON.parse(fs.readFileSync(path.join(root, '.harness', 'credentials.local.json'), 'utf8'));
|
|
158
|
+
check('creds refresh records a valid timestamp', r.status === 0 && !Number.isNaN(Date.parse(refreshed.services.surface.lastRefreshed)), r.stderr || r.stdout);
|
|
159
|
+
|
|
160
|
+
// env check/sync/detect: keys synchronize, values do not leak.
|
|
161
|
+
fs.writeFileSync(path.join(root, '.env'), 'SURFACE_PUBLIC=visible\nSURFACE_PRIVATE=do-not-copy\n', 'utf8');
|
|
162
|
+
fs.writeFileSync(path.join(root, '.env.example'), 'SURFACE_PUBLIC=\n', 'utf8');
|
|
163
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'surface-fixture', version: '1.0.0', dependencies: { react: '1.0.0', 'surface-unknown-dep': '1.0.0' } }, null, 2) + '\n');
|
|
164
|
+
r = run(root, ['env', 'check', '--json']);
|
|
165
|
+
const envBefore = parseJson(r);
|
|
166
|
+
check('env check reports keys missing from .env.example', r.status === 1 && envBefore?.inEnvOnly?.includes('SURFACE_PRIVATE'), `status=${r.status}\n${r.stdout || r.stderr}`);
|
|
167
|
+
r = run(root, ['env', 'sync']);
|
|
168
|
+
const exampleText = fs.readFileSync(path.join(root, '.env.example'), 'utf8');
|
|
169
|
+
check('env sync adds the key with an empty value only', r.status === 0 && /SURFACE_PRIVATE=\r?\n/.test(exampleText) && !exampleText.includes('do-not-copy'), r.stderr || r.stdout);
|
|
170
|
+
r = run(root, ['env', 'check', '--json']);
|
|
171
|
+
const envAfter = parseJson(r);
|
|
172
|
+
check('env check is clean after sync', r.status === 0 && envAfter?.inEnvOnly?.length === 0, r.stdout || r.stderr);
|
|
173
|
+
r = run(root, ['env', 'detect', '--json'], { timeout: 90000 });
|
|
174
|
+
const detected = parseJson(r);
|
|
175
|
+
check('env detect persists a machine-readable snapshot', r.status === 0 && detected?.persisted === true && fs.existsSync(path.join(root, '.harness', 'environment.json')), r.stdout || r.stderr);
|
|
176
|
+
|
|
177
|
+
// Adaptive wakeup interval state round-trip.
|
|
178
|
+
r = run(root, ['wakeup-interval', 'set', '600', '--json']);
|
|
179
|
+
check('wakeup-interval set stores an override', r.status === 0 && parseJson(r)?.override === 600, r.stdout || r.stderr);
|
|
180
|
+
r = run(root, ['wakeup-interval', 'get', '--json']);
|
|
181
|
+
const wakeGet = parseJson(r);
|
|
182
|
+
check('wakeup-interval get returns the fixed override', r.status === 0 && wakeGet?.interval === 600 && wakeGet?.adaptive === false, r.stdout || r.stderr);
|
|
183
|
+
r = run(root, ['wakeup-interval', 'record', 'user-trigger', '--json']);
|
|
184
|
+
check('wakeup-interval record accepts user-trigger', r.status === 0 && parseJson(r)?.recorded === 'user-trigger', r.stdout || r.stderr);
|
|
185
|
+
r = run(root, ['wakeup-interval', 'history', '--json']);
|
|
186
|
+
const wakeHistory = parseJson(r);
|
|
187
|
+
check('wakeup-interval history retains the fire', r.status === 0 && wakeHistory?.fires?.some(f => f.kind === 'user-trigger'), r.stdout || r.stderr);
|
|
188
|
+
r = run(root, ['wakeup-interval', 'auto', '--json']);
|
|
189
|
+
check('wakeup-interval auto clears the override', r.status === 0 && parseJson(r)?.override === null, r.stdout || r.stderr);
|
|
190
|
+
|
|
191
|
+
// Workspace directory, feature toggles, glossary, and policy.
|
|
192
|
+
r = run(root, ['workspace-dir', 'get', '--json']);
|
|
193
|
+
check('workspace-dir get reports .harness as the active store', r.status === 0 && parseJson(r)?.current === '.harness', r.stdout || r.stderr);
|
|
194
|
+
r = run(root, ['workspace-dir', 'guide']);
|
|
195
|
+
check('workspace-dir guide explains the canonical directory', r.status === 0 && r.stdout.includes('.harness'), r.stderr || r.stdout);
|
|
196
|
+
r = run(root, ['toggle', 'set', 'workflow-distribute', 'off', '--json']);
|
|
197
|
+
check('toggle set persists an explicit OFF value', r.status === 0 && parseJson(r)?.value === false, r.stdout || r.stderr);
|
|
198
|
+
r = run(root, ['toggle', 'list', '--json']);
|
|
199
|
+
check('toggle list returns the persisted value', r.status === 0 && parseJson(r)?.toggles?.['workflow-distribute'] === false, r.stdout || r.stderr);
|
|
200
|
+
r = run(root, ['glossary', 'build', '--json']);
|
|
201
|
+
const glossaryBuild = parseJson(r);
|
|
202
|
+
check('glossary build writes both rendered and JSON artifacts', r.status === 0 && glossaryBuild?.ok === true && fs.existsSync(path.join(root, '.harness', 'glossary.md')) && fs.existsSync(path.join(root, '.harness', 'glossary.json')), r.stdout || r.stderr);
|
|
203
|
+
r = run(root, ['glossary', 'show', '--json']);
|
|
204
|
+
const glossaryShow = parseJson(r);
|
|
205
|
+
check('glossary show exposes defined entries and unresolved gaps', r.status === 0 && glossaryShow?.entries?.some(e => e.term === 'react') && glossaryShow?.gaps?.some(e => e.term === 'surface-unknown-dep'), r.stdout || r.stderr);
|
|
206
|
+
r = run(root, ['policy', 'set', 'read-only', '--enforce', '--json']);
|
|
207
|
+
check('policy set enables enforcement at read-only', r.status === 0 && parseJson(r)?.set === 'read-only' && parseJson(r)?.enforce === true, r.stdout || r.stderr);
|
|
208
|
+
r = run(root, ['policy', 'show', '--json']);
|
|
209
|
+
check('policy show returns the saved tier', r.status === 0 && parseJson(r)?.allowedTier === 'read-only' && parseJson(r)?.enforce === true, r.stdout || r.stderr);
|
|
210
|
+
r = run(root, ['policy', 'check', 'release publish', '--json']);
|
|
211
|
+
const policyCheck = parseJson(r);
|
|
212
|
+
check('policy check blocks publish and signals exit 1', r.status === 1 && policyCheck?.allowed === false, r.stdout || r.stderr);
|
|
213
|
+
|
|
214
|
+
// PATH is diagnostic-only unless --apply is explicit.
|
|
215
|
+
r = run(root, ['path-setup', '--json'], { timeout: 30000 });
|
|
216
|
+
const pathSetup = parseJson(r);
|
|
217
|
+
check('path-setup default is read-only (applied=null)', r.status === 0 && pathSetup && pathSetup.applied === null, r.stdout || r.stderr);
|
|
218
|
+
|
|
219
|
+
// Browser/desktop bridges: inspect availability, then prove basic mode rejects actions before loading/using them.
|
|
220
|
+
r = run(root, ['web', 'check', '--json'], { timeout: 30000 });
|
|
221
|
+
check('web check returns structured capability state', r.status === 0 && typeof parseJson(r)?.installed === 'boolean', r.stdout || r.stderr);
|
|
222
|
+
const webOut = path.join(root, 'must-not-exist.png');
|
|
223
|
+
r = run(root, ['web', 'screenshot', 'https://example.invalid', '--out', webOut, '--json']);
|
|
224
|
+
check('web screenshot is permission-denied without network/output', r.status === 1 && parseJson(r)?.ok === false && !fs.existsSync(webOut), r.stdout || r.stderr);
|
|
225
|
+
r = run(root, ['web', 'extract', 'https://example.invalid', '--selector', 'body', '--json']);
|
|
226
|
+
check('web extract is permission-denied without network', r.status === 1 && parseJson(r)?.ok === false, r.stdout || r.stderr);
|
|
227
|
+
r = run(root, ['pc', 'check', '--json'], { timeout: 30000 });
|
|
228
|
+
check('pc check returns structured capability state', r.status === 0 && typeof parseJson(r)?.installed === 'boolean', r.stdout || r.stderr);
|
|
229
|
+
r = run(root, ['pc', 'click', '1', '1', '--json']);
|
|
230
|
+
check('pc click is permission-denied before desktop input', r.status === 1 && parseJson(r)?.ok === false, r.stdout || r.stderr);
|
|
231
|
+
r = run(root, ['pc', 'type', 'must-not-type', '--json']);
|
|
232
|
+
check('pc type is permission-denied before keyboard input', r.status === 1 && parseJson(r)?.ok === false, r.stdout || r.stderr);
|
|
233
|
+
const pcOut = path.join(root, 'must-not-exist-pc.png');
|
|
234
|
+
r = run(root, ['pc', 'screenshot', '--out', pcOut, '--json']);
|
|
235
|
+
check('pc screenshot is permission-denied without output', r.status === 1 && parseJson(r)?.ok === false && !fs.existsSync(pcOut), r.stdout || r.stderr);
|
|
236
|
+
|
|
237
|
+
// LSP bridge executes locally through TypeScript API or the built-in regex fallback.
|
|
238
|
+
const sample = path.join(root, 'surface-sample.js');
|
|
239
|
+
fs.writeFileSync(sample, 'function surfaceFn() { return 1; }\nmodule.exports = { surfaceFn };\n', 'utf8');
|
|
240
|
+
r = run(root, ['lsp', 'check', '--json'], { timeout: 30000 });
|
|
241
|
+
check('lsp check returns structured native/fallback state', r.status === 0 && parseJson(r)?.fallback === 'regex (always available)', r.stdout || r.stderr);
|
|
242
|
+
r = run(root, ['lsp', 'symbols', sample, '--json'], { timeout: 30000 });
|
|
243
|
+
const symbols = parseJson(r);
|
|
244
|
+
check('lsp symbols finds a local function', r.status === 0 && symbols?.symbols?.some(s => s.name === 'surfaceFn'), r.stdout || r.stderr);
|
|
245
|
+
r = run(root, ['lsp', 'references', 'surfaceFn', '--in', root, '--json'], { timeout: 30000 });
|
|
246
|
+
check('lsp references finds local usages', r.status === 0 && parseJson(r)?.count >= 2, r.stdout || r.stderr);
|
|
247
|
+
|
|
248
|
+
// Webhook listener is safe to exercise fully on localhost.
|
|
249
|
+
const port = await freePort();
|
|
250
|
+
const webhook = cp.spawn(process.execPath, [CLI, 'webhook', 'serve', '--port', String(port), '--path', root], {
|
|
251
|
+
cwd: root, env: baseEnv, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
|
252
|
+
});
|
|
253
|
+
children.add(webhook);
|
|
254
|
+
try {
|
|
255
|
+
await waitForOutput(webhook, /listening on port/);
|
|
256
|
+
const health = await request(port, 'GET', '/health');
|
|
257
|
+
const healthJson = JSON.parse(health.body);
|
|
258
|
+
check('webhook serve answers localhost health', health.status === 200 && healthJson.ok === true && healthJson.port === port, health.body);
|
|
259
|
+
const accepted = await request(port, 'POST', '/incident', { error: 'surface fixture incident' });
|
|
260
|
+
const acceptedJson = JSON.parse(accepted.body);
|
|
261
|
+
check('webhook serve accepts and persists a local incident', accepted.status === 202 && /^inc-/.test(acceptedJson.incident || '') && fs.existsSync(path.join(root, '.harness', 'incidents')), accepted.body);
|
|
262
|
+
} finally {
|
|
263
|
+
await stopChild(webhook);
|
|
264
|
+
children.delete(webhook);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Deploy auto reaches its dry-run gate but must never execute the stored command.
|
|
268
|
+
r = run(root, ['permissions', 'set', 'extended']);
|
|
269
|
+
check('permissions extended enables allowlisted shell diagnostics', r.status === 0, r.stderr || r.stdout);
|
|
270
|
+
r = run(root, ['deploy', 'auto', 'surface', '--dry-run']);
|
|
271
|
+
check('deploy auto --dry-run skips the registered command', r.status === 0 && r.stdout.includes('dry-run') && !fs.existsSync(deployMarker), r.stderr || r.stdout);
|
|
272
|
+
|
|
273
|
+
// release sync-main: a local two-branch repository, no remote, and dry-run must preserve branch + HEAD.
|
|
274
|
+
const repo = fresh('leerness-sync-main-');
|
|
275
|
+
git(repo, ['init']);
|
|
276
|
+
git(repo, ['config', 'user.email', 'surface@example.invalid']);
|
|
277
|
+
git(repo, ['config', 'user.name', 'Leerness Surface Test']);
|
|
278
|
+
fs.writeFileSync(path.join(repo, 'base.txt'), 'base\n', 'utf8');
|
|
279
|
+
git(repo, ['add', 'base.txt']);
|
|
280
|
+
git(repo, ['commit', '-m', 'base']);
|
|
281
|
+
git(repo, ['branch', '-M', 'main']);
|
|
282
|
+
git(repo, ['checkout', '-b', 'release/surface']);
|
|
283
|
+
fs.writeFileSync(path.join(repo, 'release.txt'), 'release\n', 'utf8');
|
|
284
|
+
git(repo, ['add', 'release.txt']);
|
|
285
|
+
git(repo, ['commit', '-m', 'release']);
|
|
286
|
+
const branchBefore = (git(repo, ['rev-parse', '--abbrev-ref', 'HEAD']).stdout || '').trim();
|
|
287
|
+
const headBefore = (git(repo, ['rev-parse', 'HEAD']).stdout || '').trim();
|
|
288
|
+
r = run(repo, ['release', 'sync-main', '--dry-run', '--no-npm']);
|
|
289
|
+
const branchAfter = (git(repo, ['rev-parse', '--abbrev-ref', 'HEAD']).stdout || '').trim();
|
|
290
|
+
const headAfter = (git(repo, ['rev-parse', 'HEAD']).stdout || '').trim();
|
|
291
|
+
check('release sync-main --dry-run preserves branch and HEAD without a remote', r.status === 0 && /dry-run/.test(r.stdout) && branchBefore === 'release/surface' && branchAfter === branchBefore && headAfter === headBefore, r.stderr || r.stdout);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
main().catch(error => {
|
|
295
|
+
check('command-surface runner completed without exception', false, error && error.stack ? error.stack : String(error));
|
|
296
|
+
}).finally(async () => {
|
|
297
|
+
for (const child of children) await stopChild(child);
|
|
298
|
+
for (const root of roots) {
|
|
299
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
300
|
+
}
|
|
301
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
302
|
+
console.log(`\n${total - failed}/${total} passed · ${elapsed}s`);
|
|
303
|
+
process.exitCode = failed ? 1 : 0;
|
|
304
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// scripts/e2e-concurrency.js — T-0115의 silent lost-update 회귀 방지.
|
|
2
|
+
// LOCKPROBE_STALL_*은 scripts/lock-probe.js를 명시 preload한 이 테스트에서만 활성화된다.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const cp = require('child_process');
|
|
9
|
+
|
|
10
|
+
const CLI = path.resolve(process.argv[2] || path.join(__dirname, '..', 'bin', 'leerness.js'));
|
|
11
|
+
const PRELOAD = path.join(__dirname, 'lock-probe.js');
|
|
12
|
+
const ENV = {
|
|
13
|
+
...process.env,
|
|
14
|
+
LEERNESS_OFFLINE: '1',
|
|
15
|
+
LEERNESS_NO_PROMPT: '1',
|
|
16
|
+
LEERNESS_NO_LOCK_WARN: '1'
|
|
17
|
+
};
|
|
18
|
+
const STALL_MS = 1200;
|
|
19
|
+
|
|
20
|
+
const nap = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
21
|
+
const wait = child => new Promise(resolve => child.on('close', code => resolve(code)));
|
|
22
|
+
|
|
23
|
+
function run(d, args) {
|
|
24
|
+
const r = cp.spawnSync(process.execPath, [CLI, ...args, '--path', d],
|
|
25
|
+
{ cwd: d, env: ENV, encoding: 'utf8', timeout: 60000 });
|
|
26
|
+
if (r.status !== 0) throw new Error(args.slice(0, 3).join(' ') + ' setup exit=' + r.status);
|
|
27
|
+
return r;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function init(d) {
|
|
31
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--minimal'],
|
|
32
|
+
{ cwd: d, env: ENV, encoding: 'utf8', timeout: 60000 });
|
|
33
|
+
if (r.status !== 0) throw new Error('init exit=' + r.status);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function spawn(d, args, stallTarget) {
|
|
37
|
+
const nodeArgs = stallTarget
|
|
38
|
+
? ['--require', PRELOAD, CLI, ...args, '--path', d]
|
|
39
|
+
: [CLI, ...args, '--path', d];
|
|
40
|
+
const env = stallTarget
|
|
41
|
+
? { ...ENV, LOCKPROBE_STALL_TARGET: stallTarget, LOCKPROBE_STALL_MS: String(STALL_MS) }
|
|
42
|
+
: ENV;
|
|
43
|
+
return cp.spawn(process.execPath, nodeArgs, { cwd: d, env, stdio: 'ignore' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function waitForStall(d, target) {
|
|
47
|
+
const re = new RegExp('^' + target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-');
|
|
48
|
+
const hd = path.join(d, '.harness');
|
|
49
|
+
for (let i = 0; i < 800; i++) {
|
|
50
|
+
try { if (fs.readdirSync(hd).some(name => re.test(name))) return true; } catch {}
|
|
51
|
+
nap(10);
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function race(name, config) {
|
|
57
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-t0115-race-'));
|
|
58
|
+
try {
|
|
59
|
+
init(d);
|
|
60
|
+
if (config.seed) config.seed(d);
|
|
61
|
+
const first = spawn(d, config.first, config.stallTarget);
|
|
62
|
+
const stalled = waitForStall(d, config.stallTarget);
|
|
63
|
+
const second = spawn(d, config.second);
|
|
64
|
+
const exits = await Promise.all([wait(first), wait(second)]);
|
|
65
|
+
const inspected = config.inspect(d);
|
|
66
|
+
return { name, stalled, exits, ...inspected, ok: stalled && exits.every(code => code === 0) && inspected.preserved === true };
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { name, error: String(e && e.message || e), ok: false };
|
|
69
|
+
} finally {
|
|
70
|
+
try { fs.rmSync(d, { recursive: true, force: true }); } catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
(async () => {
|
|
75
|
+
const results = [];
|
|
76
|
+
results.push(await race('creds-register', {
|
|
77
|
+
stallTarget: 'credentials.local.json',
|
|
78
|
+
first: ['creds', 'register', 'first', '--env-var', 'T0115_FIRST'],
|
|
79
|
+
second: ['creds', 'register', 'second', '--env-var', 'T0115_SECOND'],
|
|
80
|
+
inspect: d => {
|
|
81
|
+
const services = Object.keys(JSON.parse(fs.readFileSync(path.join(d, '.harness', 'credentials.local.json'), 'utf8')).services || {}).sort();
|
|
82
|
+
return { services, preserved: services.join(',') === 'first,second' };
|
|
83
|
+
}
|
|
84
|
+
}));
|
|
85
|
+
results.push(await race('creds-refresh', {
|
|
86
|
+
stallTarget: 'credentials.local.json',
|
|
87
|
+
seed: d => run(d, ['creds', 'register', 'retained', '--env-var', 'T0115_RETAINED']),
|
|
88
|
+
first: ['creds', 'refresh', 'retained'],
|
|
89
|
+
second: ['creds', 'register', 'survivor', '--env-var', 'T0115_SURVIVOR'],
|
|
90
|
+
inspect: d => {
|
|
91
|
+
const services = JSON.parse(fs.readFileSync(path.join(d, '.harness', 'credentials.local.json'), 'utf8')).services || {};
|
|
92
|
+
return { names: Object.keys(services).sort(), refreshed: !!services.retained?.lastRefreshed, preserved: !!services.retained?.lastRefreshed && !!services.survivor };
|
|
93
|
+
}
|
|
94
|
+
}));
|
|
95
|
+
results.push(await race('team-remove', {
|
|
96
|
+
stallTarget: 'teams.json',
|
|
97
|
+
seed: d => run(d, ['team', 'add', 'victim', '--name', 'victim']),
|
|
98
|
+
first: ['team', 'remove', 'victim'],
|
|
99
|
+
second: ['team', 'add', 'survivor', '--name', 'survivor'],
|
|
100
|
+
inspect: d => {
|
|
101
|
+
const ids = JSON.parse(fs.readFileSync(path.join(d, '.harness', 'teams.json'), 'utf8')).map(team => team.id).sort();
|
|
102
|
+
return { ids, preserved: ids.join(',') === 'survivor' };
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
105
|
+
results.push(await race('memory-restore-decisions', {
|
|
106
|
+
stallTarget: 'decisions.json',
|
|
107
|
+
seed: d => { run(d, ['decision', 'add', 'archived', '--why', 'test']); run(d, ['decision', 'drop', 'archived']); },
|
|
108
|
+
first: ['memory', 'restore', 'decisions', 'archived'],
|
|
109
|
+
second: ['decision', 'add', 'survivor', '--why', 'test'],
|
|
110
|
+
inspect: d => {
|
|
111
|
+
const titles = JSON.parse(fs.readFileSync(path.join(d, '.harness', 'decisions.json'), 'utf8')).map(item => item.title);
|
|
112
|
+
return { titles, preserved: titles.some(title => title.includes('archived')) && titles.some(title => title.includes('survivor')) };
|
|
113
|
+
}
|
|
114
|
+
}));
|
|
115
|
+
results.push(await race('memory-restore-lessons', {
|
|
116
|
+
stallTarget: 'lessons.json',
|
|
117
|
+
seed: d => { run(d, ['lesson', 'save', 'archived']); run(d, ['lesson', 'drop', 'archived']); },
|
|
118
|
+
first: ['memory', 'restore', 'lessons', 'archived'],
|
|
119
|
+
second: ['lesson', 'save', 'survivor'],
|
|
120
|
+
inspect: d => {
|
|
121
|
+
const texts = JSON.parse(fs.readFileSync(path.join(d, '.harness', 'lessons.json'), 'utf8')).map(item => item.text);
|
|
122
|
+
return { texts, preserved: texts.includes('archived') && texts.includes('survivor') };
|
|
123
|
+
}
|
|
124
|
+
}));
|
|
125
|
+
results.push(await race('memory-restore-plan', {
|
|
126
|
+
stallTarget: 'plan.archive.md',
|
|
127
|
+
seed: d => { run(d, ['plan', 'add', 'archived']); run(d, ['plan', 'remove', 'archived']); },
|
|
128
|
+
first: ['memory', 'restore', 'plan', 'archived'],
|
|
129
|
+
second: ['plan', 'add', 'survivor'],
|
|
130
|
+
inspect: d => {
|
|
131
|
+
const plan = fs.readFileSync(path.join(d, '.harness', 'plan.md'), 'utf8');
|
|
132
|
+
return { archived: plan.includes('archived'), survivor: plan.includes('survivor'), preserved: plan.includes('archived') && plan.includes('survivor') };
|
|
133
|
+
}
|
|
134
|
+
}));
|
|
135
|
+
const ok = results.every(result => result.ok);
|
|
136
|
+
console.log(JSON.stringify({ ok, results }));
|
|
137
|
+
if (!ok) process.exitCode = 1;
|
|
138
|
+
})().catch(error => {
|
|
139
|
+
console.error(error && error.stack || String(error));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
});
|
package/scripts/e2e-core.js
CHANGED
|
@@ -163,6 +163,14 @@ console.log('# leerness core (test:core) — flagship behavioral guarantees');
|
|
|
163
163
|
assert('lazy: TODO inside multi-line template NOT counted (FP fix)', j2 && j2.todoCount === 0);
|
|
164
164
|
fs.rmSync(d, { recursive: true, force: true });
|
|
165
165
|
}
|
|
166
|
+
// FP: README prose can describe the TODO detector without becoming a code task.
|
|
167
|
+
{
|
|
168
|
+
const d = fresh();
|
|
169
|
+
fs.writeFileSync(path.join(d, 'README.md'), '# Guide\nThe lazy detector reports untracked TODO work.\n');
|
|
170
|
+
const j = jget(d);
|
|
171
|
+
assert('lazy: README prose TODO ignored (not a code comment)', j && j.todoCount === 0);
|
|
172
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
173
|
+
}
|
|
166
174
|
}
|
|
167
175
|
|
|
168
176
|
// (9) audit: 유저-프로젝트 FP 가드 (1.35.17)
|
|
@@ -260,6 +268,22 @@ console.log('# leerness core (test:core) — flagship behavioral guarantees');
|
|
|
260
268
|
fs.rmSync(outsider, { recursive: true, force: true });
|
|
261
269
|
}
|
|
262
270
|
|
|
271
|
+
// (13) Windows child env + sessions false-success 회귀 (T-0129/T-0130)
|
|
272
|
+
{
|
|
273
|
+
const scrubCase = require('../bin/leerness')._selfTestCases().find(c => c.name.startsWith('_scrubTestEnv:'));
|
|
274
|
+
assert('child env scrub keeps executable PATH while removing test secrets', !!scrubCase && scrubCase.run() === true);
|
|
275
|
+
|
|
276
|
+
const d = fresh();
|
|
277
|
+
const call = (args) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd: d, encoding: 'utf8', timeout: 40000 });
|
|
278
|
+
const invalid = call(['sessions', 'send', '--path', d, '--json']);
|
|
279
|
+
let invalidJson = null; try { invalidJson = JSON.parse(invalid.stdout || ''); } catch {}
|
|
280
|
+
assert('sessions rejects unsupported send/inbox/scope-style verbs (no false-success)', invalid.status === 1 && invalidJson && invalidJson.code === 'unknown_subcommand');
|
|
281
|
+
const positional = call(['sessions', d, '--json']);
|
|
282
|
+
let positionalJson = null; try { positionalJson = JSON.parse(positional.stdout || ''); } catch {}
|
|
283
|
+
assert('sessions keeps the documented positional-path listing contract', positional.status === 0 && positionalJson && Array.isArray(positionalJson.sessions));
|
|
284
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
|
|
263
287
|
const dur = ((Date.now() - t0) / 1000).toFixed(1);
|
|
264
288
|
console.log(`\nCore result: ${total - failed}/${total} passed · ${dur}s`);
|
|
265
289
|
if (failed > 0) process.exit(1);
|