pi-cursor-sdk 0.1.40 → 0.1.42

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -9
  3. package/docs/cursor-dogfood-checklist.md +6 -0
  4. package/docs/cursor-live-smoke-checklist.md +4 -4
  5. package/docs/cursor-model-ux-spec.md +6 -6
  6. package/docs/cursor-native-tool-replay.md +11 -7
  7. package/docs/cursor-native-tool-visual-audit.md +2 -2
  8. package/docs/cursor-testing-lessons.md +1 -1
  9. package/docs/cursor-tool-surfaces.md +4 -0
  10. package/docs/platform-smoke.md +9 -1
  11. package/package.json +8 -5
  12. package/scripts/lib/cursor-visual-manifest.d.mts +3 -0
  13. package/scripts/lib/cursor-visual-manifest.mjs +82 -0
  14. package/scripts/platform-smoke/artifacts.mjs +147 -2
  15. package/scripts/platform-smoke/card-detect.mjs +1 -1
  16. package/scripts/platform-smoke/doctor.mjs +53 -8
  17. package/scripts/platform-smoke/scenarios.mjs +1 -1
  18. package/scripts/platform-smoke.mjs +69 -7
  19. package/scripts/visual-tui-smoke-self-test.mjs +229 -0
  20. package/scripts/visual-tui-smoke.mjs +45 -179
  21. package/src/context.ts +25 -10
  22. package/src/cursor-active-tools.ts +7 -0
  23. package/src/cursor-compact-tool-summary.ts +81 -0
  24. package/src/cursor-native-tool-display-registration.ts +31 -21
  25. package/src/cursor-native-tool-display-replay.ts +13 -2
  26. package/src/cursor-native-tool-display-state.ts +13 -4
  27. package/src/cursor-pi-tool-bridge-run.ts +6 -3
  28. package/src/cursor-pi-tool-bridge-types.ts +2 -2
  29. package/src/cursor-provider-errors.ts +2 -1
  30. package/src/cursor-provider-live-run-drain.ts +1 -1
  31. package/src/cursor-provider-turn-prepare.ts +1 -1
  32. package/src/cursor-provider-turn-send.ts +2 -0
  33. package/src/cursor-question-tool.ts +2 -1
  34. package/src/cursor-replay-activity-builders.ts +12 -4
  35. package/src/cursor-replay-summary-args.ts +21 -2
  36. package/src/cursor-sdk-event-debug.ts +3 -1
  37. package/src/cursor-skill-tool.ts +2 -1
  38. package/src/cursor-task-presentation.ts +77 -0
  39. package/src/cursor-tool-manifest.ts +2 -1
  40. package/src/cursor-tool-presentation-registry.ts +16 -2
  41. package/src/cursor-tool-result-display-readers.ts +13 -8
  42. package/src/cursor-transcript-tool-formatters.ts +5 -5
  43. package/src/cursor-usage-accounting.ts +5 -4
@@ -2,12 +2,13 @@
2
2
  * Artifact management — directory layout, manifest, redaction scanning, packaging.
3
3
  */
4
4
 
5
- import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from "node:fs";
6
- import { resolve, relative, basename } from "node:path";
5
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, renameSync } from "node:fs";
6
+ import { resolve, relative, basename, dirname } from "node:path";
7
7
 
8
8
  const PLATFORM_SMOKE_RUN_DIR_PATTERN = /^run-(\d+)-[a-z0-9]+$/i;
9
9
  const HOURS_TO_MS = 60 * 60 * 1000;
10
10
  const DAYS_TO_MS = 24 * HOURS_TO_MS;
11
+ const LATEST_INDEX_NAME = "latest.json";
11
12
 
12
13
  function finiteNonNegativeNumber(value) {
13
14
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -178,6 +179,150 @@ export function writeSummary(dir, data) {
178
179
  }, null, 2));
179
180
  }
180
181
 
182
+ function readJsonFile(path) {
183
+ try {
184
+ return JSON.parse(readFileSync(path, "utf8"));
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ function collectFiles(root) {
191
+ const files = [];
192
+ function walk(dir) {
193
+ let entries;
194
+ try {
195
+ entries = readdirSync(dir, { withFileTypes: true });
196
+ } catch {
197
+ return;
198
+ }
199
+ for (const entry of entries) {
200
+ const path = resolve(dir, entry.name);
201
+ if (entry.isDirectory()) walk(path);
202
+ else if (entry.isFile()) files.push(path);
203
+ }
204
+ }
205
+ if (existsSync(root)) walk(root);
206
+ files.sort();
207
+ return files;
208
+ }
209
+
210
+ function existingPath(path) {
211
+ return existsSync(path) ? path : undefined;
212
+ }
213
+
214
+ function providerDebugPathFields(debugRoot) {
215
+ if (!existsSync(debugRoot)) return {};
216
+ const providerDebugArtifacts = collectFiles(debugRoot);
217
+ const keyArtifacts = providerDebugArtifacts.filter((path) => /(?:^|[\\/])(?:session|summary|timeline|provider-events|bridge-events|wait-result)\.(?:json|jsonl)$/i.test(path));
218
+ const capped = keyArtifacts.slice(0, 40);
219
+ return {
220
+ providerDebugRoot: debugRoot,
221
+ providerDebugArtifactCount: providerDebugArtifacts.length,
222
+ providerDebugArtifacts: capped,
223
+ ...(providerDebugArtifacts.length > capped.length ? { providerDebugArtifactsTruncated: true } : {}),
224
+ };
225
+ }
226
+
227
+ function pathFields(suiteDir) {
228
+ const artifactsDir = resolve(suiteDir, "artifacts");
229
+ const debugRoot = resolve(suiteDir, "cursor-sdk-events");
230
+ const paths = {
231
+ artifactManifest: existingPath(resolve(suiteDir, "artifact-manifest.json")),
232
+ summary: existingPath(resolve(suiteDir, "summary.json")),
233
+ assertions: existingPath(resolve(suiteDir, "assertions.json")),
234
+ failures: existingPath(resolve(suiteDir, "failures.md")),
235
+ terminalHtml: existingPath(resolve(artifactsDir, "terminal.html")),
236
+ terminalFullPng: existingPath(resolve(artifactsDir, "terminal.full.png")),
237
+ terminalFinalViewportPng: existingPath(resolve(artifactsDir, "terminal.final-viewport.png")),
238
+ visualEvidence: existingPath(resolve(artifactsDir, "visual-evidence.json")),
239
+ sessionJsonl: existingPath(resolve(artifactsDir, "session.jsonl")),
240
+ jsonlToolResults: existingPath(resolve(artifactsDir, "jsonl-tool-results.json")),
241
+ ...providerDebugPathFields(debugRoot),
242
+ };
243
+ for (const [key, value] of Object.entries(paths)) {
244
+ if (value === undefined) delete paths[key];
245
+ }
246
+ return paths;
247
+ }
248
+
249
+ function suiteIndexFromResult(result, artifactRoot) {
250
+ if (!result?.suiteDir) return undefined;
251
+ const suiteDir = resolve(result.suiteDir);
252
+ const summary = readJsonFile(resolve(suiteDir, "summary.json"));
253
+ const target = readJsonFile(resolve(suiteDir, "target.json"));
254
+ const suite = readJsonFile(resolve(suiteDir, "suite.json"));
255
+ const rel = relative(resolve(process.cwd(), artifactRoot), suiteDir).split(/[\\/]/);
256
+ return {
257
+ target: summary?.target ?? target?.targetName ?? rel.at(-2),
258
+ suite: summary?.suite ?? suite?.suiteName ?? rel.at(-1),
259
+ runId: target?.runId ?? rel.at(-3),
260
+ ok: result.ok === true,
261
+ artifactDir: suiteDir,
262
+ paths: pathFields(suiteDir),
263
+ };
264
+ }
265
+
266
+ function targetIndexesFromRun(targetName, result, artifactRoot) {
267
+ const suiteResults = Array.isArray(result?.results) ? result.results : [result];
268
+ const suites = suiteResults.map((suiteResult) => suiteIndexFromResult(suiteResult, artifactRoot)).filter(Boolean);
269
+ const runIds = [...new Set(suites.map((suite) => suite.runId).filter(Boolean))];
270
+ return {
271
+ target: targetName,
272
+ ok: result?.ok === true,
273
+ ...(result?.error ? { error: redactSecrets(result.error) } : {}),
274
+ runId: runIds.length === 1 ? runIds[0] : undefined,
275
+ runIds,
276
+ suites,
277
+ };
278
+ }
279
+
280
+ /** Build a stable, agent-readable platform-smoke latest index from target run results. */
281
+ export function buildLatestPlatformSmokeIndex(config, runResults, metadata = {}) {
282
+ const artifactRoot = resolve(process.cwd(), config?.artifactRoot ?? ".artifacts/platform-smoke");
283
+ const targets = runResults.map(({ targetName, result }) => targetIndexesFromRun(targetName, result, artifactRoot));
284
+ const runIds = [...new Set(targets.flatMap((target) => target.runIds).filter(Boolean))].sort();
285
+ const newestRunId = runIds
286
+ .map((runId) => ({ runId, match: PLATFORM_SMOKE_RUN_DIR_PATTERN.exec(runId) }))
287
+ .filter((entry) => entry.match)
288
+ .sort((a, b) => Number(b.match[1]) - Number(a.match[1]))[0]?.runId ?? runIds.at(-1);
289
+ return {
290
+ schemaVersion: 1,
291
+ kind: "platform-smoke-latest",
292
+ runId: runIds.length === 1 ? runIds[0] : newestRunId,
293
+ runIds,
294
+ artifactRoot,
295
+ startedAt: metadata.startedAt,
296
+ finishedAt: metadata.finishedAt,
297
+ command: metadata.command,
298
+ pid: process.pid,
299
+ ok: targets.every((target) => target.ok),
300
+ targets,
301
+ };
302
+ }
303
+
304
+ /** Atomically write .artifacts/platform-smoke/latest.json. */
305
+ export function writeLatestPlatformSmokeIndex(config, runResults, metadata = {}) {
306
+ const index = buildLatestPlatformSmokeIndex(config, runResults, metadata);
307
+ mkdirSync(index.artifactRoot, { recursive: true });
308
+ const outPath = resolve(index.artifactRoot, LATEST_INDEX_NAME);
309
+ const tmpPath = resolve(dirname(outPath), `.${LATEST_INDEX_NAME}.${process.pid}.${Date.now()}.tmp`);
310
+ writeFileSync(tmpPath, `${JSON.stringify(index, null, 2)}\n`);
311
+ renameSync(tmpPath, outPath);
312
+ return { index, path: outPath };
313
+ }
314
+
315
+ /** Return concise existing evidence paths for a failed suite result. */
316
+ export function platformSmokeSuiteEvidence(result, artifactRoot) {
317
+ const suite = suiteIndexFromResult(result, artifactRoot ?? ".artifacts/platform-smoke");
318
+ if (!suite) return undefined;
319
+ return {
320
+ suite: suite.suite,
321
+ artifactDir: suite.artifactDir,
322
+ paths: suite.paths,
323
+ };
324
+ }
325
+
181
326
  /** Write command.txt recording the command that was executed. */
182
327
  export function writeCommand(dir, cmd) {
183
328
  writeFileSync(resolve(dir, "command.txt"), Array.isArray(cmd) ? cmd.join(" ") + "\n" : cmd + "\n");
@@ -18,7 +18,7 @@ const CARD_PATTERNS = [
18
18
  { id: "write", pattern: /^\s*\+.*beta\s*$/i },
19
19
  { id: "edit-diff", pattern: /^\s*\+.*gamma\s*$/i },
20
20
  { id: "shell-failure", pattern: /^\s*(?:native shell failure|Command exited with code 7)\s*$/i },
21
- { id: "bridge-read-success", pattern: /^\s*read \.\/package\.json\s*$/i },
21
+ { id: "bridge-read-success", pattern: /^\s*read (?:\.\/package\.json|.*[\\/]package\.json)\s*$/i },
22
22
  { id: "bridge-read-failure", pattern: /^\s*(?:read \.\/definitely-missing-platform-smoke-file\.txt|ENOENT: no such file)\s*/i },
23
23
  { id: "bridge-shell-success", pattern: /^\s*bridge visual smoke\s*$/i },
24
24
  { id: "footer-status", pattern: /\bcomposer-2-5\b|\bcomposer-2\.5\b/i },
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { execSync, execFileSync } from "node:child_process";
10
- import { accessSync, constants, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
10
+ import { accessSync, constants, existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, statSync } from "node:fs";
11
11
  import { dirname, resolve } from "node:path";
12
+ import { renderAll } from "./render-ansi.mjs";
12
13
 
13
14
  let failures = 0;
14
15
 
@@ -126,6 +127,41 @@ function disposableWindowsSshProbe(cbox, config = {}) {
126
127
 
127
128
  function hasBin(name) { return silent("which", [name]) !== null; }
128
129
 
130
+ async function runRenderProbe(artifactRoot) {
131
+ const probeDir = resolve(artifactRoot, `.doctor-render-${process.pid}-${Date.now()}`);
132
+ try {
133
+ mkdirSync(probeDir, { recursive: true });
134
+ const ansiPath = resolve(probeDir, "terminal.ansi");
135
+ writeFileSync(ansiPath, "\u001b[32mplatform smoke render probe\u001b[0m\n");
136
+ const rendered = await renderAll(ansiPath, probeDir, {
137
+ label: "doctor-render-probe",
138
+ model: "doctor",
139
+ mode: "doctor",
140
+ cwd: process.cwd(),
141
+ sessionId: "doctor-render-probe",
142
+ width: 80,
143
+ height: 10,
144
+ historyLines: 100,
145
+ });
146
+ const pngPath = resolve(probeDir, "terminal.full.png");
147
+ const pngOk = rendered.pngOk && existsSync(pngPath) && statSync(pngPath).size > 100;
148
+ if (!pngOk) {
149
+ return {
150
+ ok: false,
151
+ message: `host-side xterm/Playwright render probe did not produce a PNG at ${pngPath}. Run: npx playwright install chromium`,
152
+ };
153
+ }
154
+ return { ok: true, message: pngPath };
155
+ } catch (error) {
156
+ return {
157
+ ok: false,
158
+ message: `host-side xterm/Playwright render probe failed: ${error instanceof Error ? error.message : String(error)}. Run npm install, then: npx playwright install chromium`,
159
+ };
160
+ } finally {
161
+ rmSync(probeDir, { recursive: true, force: true });
162
+ }
163
+ }
164
+
129
165
  function findGitRoot(startPath) {
130
166
  let dir = startPath;
131
167
  for (let i = 0; i < 8; i++) {
@@ -137,7 +173,7 @@ function findGitRoot(startPath) {
137
173
  return null;
138
174
  }
139
175
 
140
- function runChecks(config) {
176
+ async function runChecks(config) {
141
177
  // ── Phase 1: environment variables ──
142
178
  console.log("\n── Environment variables ──");
143
179
  const requiredVars = [
@@ -396,7 +432,16 @@ function runChecks(config) {
396
432
  fail(`cannot write to ${artRoot}: ${e.message}`);
397
433
  }
398
434
 
399
- // ── Phase 10: Git status ──
435
+ // ── Phase 10: Host-side visual render probe ──
436
+ console.log("\n── Host-side visual render probe ──");
437
+ const renderProbe = await runRenderProbe(artRoot);
438
+ if (renderProbe.ok) {
439
+ ok("xterm/Playwright Chromium render probe wrote a PNG");
440
+ } else {
441
+ fail(renderProbe.message);
442
+ }
443
+
444
+ // ── Phase 11: Git status ──
400
445
  console.log("\n── Git status ──");
401
446
  const branch = shell("git branch --show-current");
402
447
  branch ? ok(`branch: ${branch}`) : warn("could not determine branch");
@@ -408,7 +453,7 @@ function runChecks(config) {
408
453
  ok("clean worktree");
409
454
  }
410
455
 
411
- // ── Phase 11: Forbidden files ──
456
+ // ── Phase 12: Forbidden files ──
412
457
  console.log("\n── Forbidden files ──");
413
458
  let anyForbidden = false;
414
459
  for (const pat of [".env", "*.tgz"]) {
@@ -428,7 +473,7 @@ function runChecks(config) {
428
473
  }
429
474
  ok("no tracked .env.*");
430
475
 
431
- // ── Phase 12: Cursor auth ──
476
+ // ── Phase 13: Cursor auth ──
432
477
  console.log("\n── Cursor auth ──");
433
478
  const key = env("CURSOR_API_KEY");
434
479
  if (key && key.length > 10) {
@@ -439,7 +484,7 @@ function runChecks(config) {
439
484
  fail("CURSOR_API_KEY missing — live Cursor suites will not run");
440
485
  }
441
486
 
442
- // ── Phase 13: node-pty self-test ──
487
+ // ── Phase 14: node-pty self-test ──
443
488
  console.log("\n── node-pty self-test ──");
444
489
  const ptyPath = resolve(process.cwd(), "node_modules", "node-pty");
445
490
  if (existsSync(ptyPath)) {
@@ -458,7 +503,7 @@ function runChecks(config) {
458
503
  warn("node-pty not installed — live PTY suites will not run. Run: npm ci");
459
504
  }
460
505
 
461
- // ── Phase 14: Summary ──
506
+ // ── Phase 15: Summary ──
462
507
  console.log(`\n=== Results: ${failures} failure(s) ===`);
463
508
  if (failures > 0) {
464
509
  console.log("Fix failures above before running live Cursor suites.");
@@ -470,5 +515,5 @@ function runChecks(config) {
470
515
  }
471
516
 
472
517
  export async function runDoctor(config) {
473
- runChecks(config);
518
+ await runChecks(config);
474
519
  }
@@ -131,7 +131,7 @@ BRIDGE_MATRIX_OK bash_ok=<yes/no> read_ok=<yes/no> read_missing_error=<yes/no>`,
131
131
  { id: "bridge-shell-success", toolName: "bash", isError: false, contains: "bridge visual smoke" },
132
132
  ],
133
133
  visualEvidence: [
134
- { id: "bridge-read-success", pattern: "^\\s*read \\./package\\.json", jsonlResultId: "bridge-read-success" },
134
+ { id: "bridge-read-success", pattern: "^\\s*read (?:\\./package\\.json|.*[\\\\/]package\\.json)", jsonlResultId: "bridge-read-success" },
135
135
  { id: "bridge-read-failure", pattern: "^\\s*read \\./definitely-missing-platform-smoke-file\\.txt|ENOENT: no such file", jsonlResultId: "bridge-read-failure" },
136
136
  { id: "bridge-shell-success", pattern: "^\\s*bridge visual smoke\\s*$", jsonlResultId: "bridge-shell-success" },
137
137
  ],
@@ -3,9 +3,9 @@
3
3
  import { createRequire } from "node:module";
4
4
  import { resolve, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { accessSync, constants } from "node:fs";
6
+ import { existsSync } from "node:fs";
7
7
 
8
- import { prunePlatformSmokeArtifacts } from "./platform-smoke/artifacts.mjs";
8
+ import { platformSmokeSuiteEvidence, prunePlatformSmokeArtifacts, redactSecrets, writeLatestPlatformSmokeIndex } from "./platform-smoke/artifacts.mjs";
9
9
 
10
10
  // ── helpers ────────────────────────────────────────────────────────────────
11
11
  const __filename = fileURLToPath(import.meta.url);
@@ -104,6 +104,53 @@ function validateSelections(targets, suites) {
104
104
  }
105
105
  }
106
106
 
107
+ function failedSuiteResults(result) {
108
+ if (!result) return [];
109
+ if (Array.isArray(result.results)) return result.results.filter((suiteResult) => suiteResult?.ok !== true);
110
+ return result.ok === true ? [] : [result];
111
+ }
112
+
113
+ function formatExistingPath(label, path) {
114
+ return path && existsSync(path) ? ` ${label}: ${path}` : undefined;
115
+ }
116
+
117
+ function printFailureEvidence(results, artifactRoot) {
118
+ const failed = [];
119
+ for (const { targetName, result } of results) {
120
+ let targetEvidenceCount = 0;
121
+ for (const suiteResult of failedSuiteResults(result)) {
122
+ const evidence = platformSmokeSuiteEvidence(suiteResult, artifactRoot);
123
+ if (evidence) {
124
+ targetEvidenceCount++;
125
+ failed.push({ targetName, ...evidence });
126
+ }
127
+ }
128
+ if (targetEvidenceCount === 0 && result?.ok !== true && result?.error) {
129
+ failed.push({ targetName, suite: "target", error: result.error });
130
+ }
131
+ }
132
+ if (failed.length === 0) return;
133
+ console.log("\nFailed suite artifacts:");
134
+ for (const item of failed) {
135
+ const paths = item.paths ?? {};
136
+ console.log(`- Suite: ${item.targetName}/${item.suite}`);
137
+ if (item.error) console.log(` Target error: ${item.error}`);
138
+ const lines = [
139
+ formatExistingPath("Artifact dir", item.artifactDir),
140
+ formatExistingPath("Assertions", paths.assertions),
141
+ formatExistingPath("Failures", paths.failures),
142
+ formatExistingPath("Terminal HTML", paths.terminalHtml),
143
+ formatExistingPath("Terminal full PNG", paths.terminalFullPng),
144
+ formatExistingPath("Terminal final viewport PNG", paths.terminalFinalViewportPng),
145
+ formatExistingPath("Visual evidence", paths.visualEvidence),
146
+ formatExistingPath("Session JSONL", paths.sessionJsonl),
147
+ formatExistingPath("JSONL tool results", paths.jsonlToolResults),
148
+ formatExistingPath("Provider/Cursor debug artifacts", paths.providerDebugRoot),
149
+ ].filter(Boolean);
150
+ for (const line of lines) console.log(line);
151
+ }
152
+ }
153
+
107
154
  // ── commands ───────────────────────────────────────────────────────────────
108
155
  async function runDoctor() {
109
156
  try {
@@ -125,8 +172,9 @@ async function runSuite(targetName, suiteName) {
125
172
  const result = await runTargetSuite(config, targetName, suiteName);
126
173
  return result;
127
174
  } catch (err) {
128
- console.error(`suite ${suiteName} on ${targetName} exception:`, err.message);
129
- return { ok: false, error: err.message };
175
+ const message = redactSecrets(err.message);
176
+ console.error(`suite ${suiteName} on ${targetName} exception:`, message);
177
+ return { ok: false, error: message };
130
178
  }
131
179
  }
132
180
 
@@ -135,8 +183,9 @@ async function runTarget(targetName, suites) {
135
183
  const { runTargetSuites } = await import("./platform-smoke/targets.mjs");
136
184
  return await runTargetSuites(config, targetName, suites);
137
185
  } catch (err) {
138
- console.error(`target ${targetName} exception:`, err.message);
139
- return { ok: false, error: err.message };
186
+ const message = redactSecrets(err.message);
187
+ console.error(`target ${targetName} exception:`, message);
188
+ return { ok: false, error: message };
140
189
  }
141
190
  }
142
191
 
@@ -179,6 +228,7 @@ async function main() {
179
228
  console.log(`Pruned ${pruneResult.removed.length} old platform smoke artifact run(s) from ${pruneResult.root}`);
180
229
  }
181
230
 
231
+ const startedAt = new Date().toISOString();
182
232
  const targetRuns = targets.map(async (targetName) => {
183
233
  console.log(`\n=== Target: ${targetName} ===`);
184
234
  const result = args.suite
@@ -187,9 +237,21 @@ async function main() {
187
237
  return { targetName, result };
188
238
  });
189
239
  const results = await Promise.all(targetRuns);
240
+ const finishedAt = new Date().toISOString();
241
+ const latest = writeLatestPlatformSmokeIndex(config, results, {
242
+ startedAt,
243
+ finishedAt,
244
+ command: {
245
+ cwd: process.cwd(),
246
+ targets,
247
+ suites,
248
+ },
249
+ });
250
+ console.log(`\nArtifact index: ${latest.path}`);
190
251
  const anyFailed = results.some(({ result }) => !result.ok);
191
252
  if (anyFailed) {
192
- console.log("\nOne or more suites failed. Check .artifacts/platform-smoke/ for details.");
253
+ printFailureEvidence(results, config.artifactRoot);
254
+ console.log("\nOne or more suites failed.");
193
255
  process.exit(1);
194
256
  }
195
257
  return;
@@ -0,0 +1,229 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync, utimesSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { delimiter } from "node:path";
4
+ import { dirname, join } from "node:path";
5
+
6
+ function assertSelfTest(condition, message) {
7
+ if (!condition) throw new Error(`self-test failed: ${message}`);
8
+ }
9
+
10
+ function envMap(assignments) {
11
+ return new Map(assignments.map(([name, value]) => [name, value]));
12
+ }
13
+
14
+ function parseEnvCapture(path) {
15
+ return new Map(
16
+ readFileSync(path, "utf8")
17
+ .split("\n")
18
+ .filter(Boolean)
19
+ .map((line) => {
20
+ const index = line.indexOf("=");
21
+ return index === -1 ? [line, ""] : [line.slice(0, index), line.slice(index + 1)];
22
+ }),
23
+ );
24
+ }
25
+
26
+ export function runVisualSmokeSelfTest(deps) {
27
+ const { ROOT, DEFAULT_MODE, DEFAULT_MODEL, DEFAULT_SETTING_SOURCES, DEBUG_ENV_NAMES, shellQuote, parseArgs, snapshotJsonlMtimes, findLatestJsonl, sealedNodePath, resolveCommand, requireNode, requireCommand, buildLaunchPlan, run, runVisualSmoke } = deps;
28
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-visual-self-test-"));
29
+ try {
30
+ const binDir = join(tempDir, "bin");
31
+ mkdirSync(binDir, { recursive: true });
32
+ const fakePi = join(binDir, "pi");
33
+ const fakeNode = join(binDir, "node");
34
+ const fakeNodeMarker = join(tempDir, "fake-node-used");
35
+ const envCapture = join(tempDir, "fake-pi.env");
36
+ writeFileSync(
37
+ fakePi,
38
+ `#!/usr/bin/env node\nconst { writeFileSync } = require("node:fs");\nwriteFileSync(${JSON.stringify(envCapture)}, Object.entries(process.env).map(([key, value]) => key + "=" + (value ?? "")).join("\\n") + "\\n", "utf8");\n`,
39
+ "utf8",
40
+ );
41
+ writeFileSync(fakeNode, `#!/bin/sh\necho fake-node-used > ${shellQuote(fakeNodeMarker)}\nexit 99\n`, "utf8");
42
+ chmodSync(fakePi, 0o755);
43
+ chmodSync(fakeNode, 0o755);
44
+
45
+ const promptFile = join(tempDir, "prompt.txt");
46
+ writeFileSync(promptFile, "file prompt", "utf8");
47
+ assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt-file", promptFile, "--prompt", "inline prompt"]).prompt === "inline prompt", "--prompt should override an earlier --prompt-file");
48
+ assertSelfTest(parseArgs(["--label", "prompt-dash", "--prompt", "--starts-with-dash"]).prompt === "--starts-with-dash", "--prompt should accept dash-prefixed free-form text");
49
+ assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt", "inline prompt", "--prompt-file", promptFile]).prompt === "file prompt", "--prompt-file should override an earlier --prompt");
50
+
51
+ const jsonlDir = join(tempDir, "jsonl-filter");
52
+ mkdirSync(jsonlDir, { recursive: true });
53
+ const staleJsonl = join(jsonlDir, "stale.jsonl");
54
+ const freshJsonl = join(jsonlDir, "fresh.jsonl");
55
+ writeFileSync(staleJsonl, "{}\n", "utf8");
56
+ utimesSync(staleJsonl, new Date(1_000), new Date(1_000));
57
+ const previousJsonlMtimes = snapshotJsonlMtimes(jsonlDir);
58
+ writeFileSync(freshJsonl, "{}\n", "utf8");
59
+ utimesSync(freshJsonl, new Date(3_000), new Date(3_000));
60
+ assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 2_000, previousMtimes: previousJsonlMtimes }) === freshJsonl, "JSONL discovery should ignore unchanged stale files before run start");
61
+ assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 4_000, previousMtimes: snapshotJsonlMtimes(jsonlDir) }) === undefined, "JSONL discovery should not return stale evidence when current run has no changed JSONL");
62
+
63
+ assertSelfTest(!sealedNodePath(process.execPath, "").includes(delimiter), "empty inherited PATH must not leave an empty PATH segment");
64
+ const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
65
+ const sealedHostilePath = sealedNodePath(process.execPath, hostilePath);
66
+ assertSelfTest(resolveCommand("pi", hostilePath) === fakePi, "direct PATH resolver did not prefer fake PATH head");
67
+ assertSelfTest(requireNode() === process.execPath, "node resolver must use process.execPath");
68
+ assertSelfTest(requireCommand("pi", { envPath: hostilePath, env: { ...process.env, PATH: sealedHostilePath } }) === fakePi, "pi prereq should use sealed PATH when executing the shim");
69
+ assertSelfTest(!existsSync(fakeNodeMarker), "pi prereq should not use hostile fake node");
70
+
71
+ const baseOptions = {
72
+ ext: ROOT,
73
+ cwd: ROOT,
74
+ mode: DEFAULT_MODE,
75
+ model: DEFAULT_MODEL,
76
+ outDir: tempDir,
77
+ safeLabel: "self-test",
78
+ sessionDir: join(tempDir, "session"),
79
+ sessionId: "self-test",
80
+ settingSources: DEFAULT_SETTING_SOURCES,
81
+ bridge: false,
82
+ exposeBuiltinTools: false,
83
+ eventDebug: false,
84
+ };
85
+ const plan = buildLaunchPlan(baseOptions, { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath }, "/bin/sh");
86
+ const defaults = envMap(plan.envAssignments);
87
+ assertSelfTest(defaults.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "native display must be forced on");
88
+ assertSelfTest(defaults.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "native tool registration must be forced on");
89
+ assertSelfTest(defaults.get("PI_CURSOR_SETTING_SOURCES") === "none", "setting sources must default to none");
90
+ assertSelfTest(defaults.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "bridge must default off");
91
+ assertSelfTest(defaults.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "built-in exposure must default off");
92
+ for (const name of DEBUG_ENV_NAMES) {
93
+ assertSelfTest(plan.clearEnvNames.includes(name), `${name} must be cleared by default`);
94
+ }
95
+ assertSelfTest(plan.script.includes(shellQuote(fakePi)), "launch script must use resolved pi path");
96
+ assertSelfTest(!plan.script.includes(" exec pi "), "launch script must not use bare pi");
97
+ const hostileEnv = {
98
+ ...process.env,
99
+ ...Object.fromEntries(DEBUG_ENV_NAMES.map((name) => [name, join(tempDir, name)])),
100
+ PATH: hostilePath,
101
+ PI_CURSOR_REGISTER_NATIVE_TOOLS: "0",
102
+ PI_CURSOR_SETTING_SOURCES: "all",
103
+ PI_CURSOR_PI_TOOL_BRIDGE: "1",
104
+ PI_CURSOR_EXPOSE_BUILTIN_TOOLS: "1",
105
+ };
106
+ const probe = run("/bin/sh", ["-c", plan.script], { env: hostileEnv });
107
+ assertSelfTest(probe.status === 0, `fake-pi env capture exited ${probe.status}: ${probe.stderr?.toString() ?? ""}`);
108
+ const capturedEnv = parseEnvCapture(envCapture);
109
+ assertSelfTest(!existsSync(fakeNodeMarker), "launch PATH should force the resolved node before hostile fake node");
110
+ assertSelfTest((capturedEnv.get("PATH") ?? "").split(delimiter)[0] === dirname(process.execPath), "captured PATH should start with resolved node directory");
111
+ assertSelfTest(capturedEnv.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "captured env should force native display on");
112
+ assertSelfTest(capturedEnv.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "captured env should force native registration on");
113
+ assertSelfTest(capturedEnv.get("PI_CURSOR_SETTING_SOURCES") === "none", "captured env should force settings off");
114
+ assertSelfTest(capturedEnv.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "captured env should force bridge off");
115
+ assertSelfTest(capturedEnv.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "captured env should force built-in exposure off");
116
+ for (const name of DEBUG_ENV_NAMES) {
117
+ assertSelfTest(!capturedEnv.has(name), `${name} should be absent from captured env by default`);
118
+ }
119
+
120
+ const optInPlan = buildLaunchPlan(
121
+ { ...baseOptions, settingSources: "all", bridge: true, exposeBuiltinTools: true, eventDebug: true },
122
+ { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath },
123
+ "/bin/sh",
124
+ );
125
+ const optIns = envMap(optInPlan.envAssignments);
126
+ assertSelfTest(optIns.get("PI_CURSOR_SETTING_SOURCES") === "all", "setting source opt-in must be reflected");
127
+ assertSelfTest(optIns.get("PI_CURSOR_PI_TOOL_BRIDGE") === "1", "bridge opt-in must be reflected");
128
+ assertSelfTest(optIns.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "1", "built-in exposure opt-in must be reflected");
129
+ assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug opt-in must be reflected");
130
+ assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG_DIR") === join(tempDir, "self-test.cursor-sdk-events"), "event debug dir must be deterministic under out-dir");
131
+ for (const name of DEBUG_ENV_NAMES) {
132
+ assertSelfTest(optInPlan.clearEnvNames.includes(name), `${name} must be cleared even when event debug is explicit`);
133
+ }
134
+ const eventDebugProbe = run("/bin/sh", ["-c", optInPlan.script], { env: hostileEnv });
135
+ assertSelfTest(eventDebugProbe.status === 0, `fake-pi event-debug env capture exited ${eventDebugProbe.status}: ${eventDebugProbe.stderr?.toString() ?? ""}`);
136
+ const capturedEventDebugEnv = parseEnvCapture(envCapture);
137
+ assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug should be explicitly enabled");
138
+ assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG_DIR") === join(tempDir, "self-test.cursor-sdk-events"), "event debug dir should be deterministic under out-dir");
139
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR"), "stale event debug run dir should be cleared");
140
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR"), "stale event debug session dir should be cleared");
141
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_STDERR"), "stale event debug stderr flag should be cleared");
142
+
143
+ const fakeTmux = join(binDir, "tmux");
144
+ const deleteBufferMarker = join(tempDir, "delete-buffer-called");
145
+ writeFileSync(
146
+ fakeTmux,
147
+ `#!/bin/sh\ncase "$1" in\n -V) echo 'tmux fake'; exit 0 ;;\n new-session) exit 0 ;;\n load-buffer) cat >/dev/null; exit 0 ;;\n paste-buffer) exit 77 ;;\n delete-buffer) echo deleted > ${shellQuote(deleteBufferMarker)}; exit 0 ;;\n kill-session) exit 0 ;;\n *) echo "unexpected tmux command: $*" >&2; exit 64 ;;\nesac\n`,
148
+ "utf8",
149
+ );
150
+ chmodSync(fakeTmux, 0o755);
151
+ const originalPath = process.env.PATH;
152
+ try {
153
+ process.env.PATH = hostilePath;
154
+ let pasteFailed = false;
155
+ try {
156
+ runVisualSmoke({
157
+ ...baseOptions,
158
+ prompt: "buffer cleanup prompt",
159
+ startupMs: 1,
160
+ waitMs: 1,
161
+ width: 80,
162
+ height: 24,
163
+ historyLines: 100,
164
+ });
165
+ } catch (error) {
166
+ pasteFailed = /paste-buffer failed/.test(error instanceof Error ? error.message : String(error));
167
+ }
168
+ assertSelfTest(pasteFailed, "fake tmux paste failure should exercise prompt-buffer cleanup path");
169
+ assertSelfTest(existsSync(deleteBufferMarker), "prompt tmux buffer should be deleted when paste/send fails");
170
+ } finally {
171
+ if (originalPath === undefined) delete process.env.PATH;
172
+ else process.env.PATH = originalPath;
173
+ }
174
+
175
+ writeFileSync(
176
+ fakeTmux,
177
+ `#!/bin/sh
178
+ case "$1" in
179
+ -V) echo 'tmux fake'; exit 0 ;;
180
+ new-session) exit 0 ;;
181
+ load-buffer) cat >/dev/null; exit 0 ;;
182
+ paste-buffer) exit 0 ;;
183
+ send-keys) exit 0 ;;
184
+ delete-buffer) exit 0 ;;
185
+ capture-pane) echo 'captured visual smoke output'; exit 0 ;;
186
+ kill-session) exit 0 ;;
187
+ *) echo "unexpected tmux command: $*" >&2; exit 64 ;;
188
+ esac
189
+ `,
190
+ "utf8",
191
+ );
192
+ chmodSync(fakeTmux, 0o755);
193
+ const noJsonlManifest = join(tempDir, "self-test-jsonl-missing.manifest.json");
194
+ try {
195
+ process.env.PATH = hostilePath;
196
+ let missingJsonlFailed = false;
197
+ let missingJsonlError = "";
198
+ try {
199
+ runVisualSmoke({
200
+ ...baseOptions,
201
+ label: "self-test-jsonl-missing",
202
+ safeLabel: "self-test-jsonl-missing",
203
+ prompt: "jsonl failure prompt",
204
+ startupMs: 1,
205
+ waitMs: 1,
206
+ width: 80,
207
+ height: 24,
208
+ historyLines: 100,
209
+ sessionDir: join(tempDir, "missing-jsonl-session"),
210
+ });
211
+ } catch (error) {
212
+ missingJsonlError = error instanceof Error ? error.message : String(error);
213
+ missingJsonlFailed = /no current-run persisted \.jsonl/.test(missingJsonlError);
214
+ }
215
+ assertSelfTest(missingJsonlFailed, `missing JSONL should fail after partial visual artifacts are written: ${missingJsonlError || "no error"}`);
216
+ assertSelfTest(existsSync(noJsonlManifest), "missing JSONL should still write a failure manifest");
217
+ const manifest = JSON.parse(readFileSync(noJsonlManifest, "utf8"));
218
+ assertSelfTest(manifest.failure?.message?.includes("no current-run persisted .jsonl"), "failure manifest should record the missing JSONL reason");
219
+ assertSelfTest(manifest.paths?.html?.endsWith("self-test-jsonl-missing.html"), "failure manifest should point at partial HTML evidence");
220
+ } finally {
221
+ if (originalPath === undefined) delete process.env.PATH;
222
+ else process.env.PATH = originalPath;
223
+ }
224
+ console.log("[visual-smoke] self-test PASS");
225
+ } finally {
226
+ rmSync(tempDir, { recursive: true, force: true });
227
+ }
228
+ }
229
+