pi-background-run 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -22
- package/extension/index.test.ts +644 -45
- package/extension/index.ts +524 -208
- package/package.json +1 -1
- package/skill/run-bg/SKILL.md +7 -4
package/extension/index.test.ts
CHANGED
|
@@ -35,6 +35,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
35
35
|
wakes: CapturedWake[];
|
|
36
36
|
entries: any[];
|
|
37
37
|
tools: Map<string, { execute: (...args: any[]) => Promise<any> }>;
|
|
38
|
+
commands: Map<
|
|
39
|
+
string,
|
|
40
|
+
{ description?: string; handler: (...args: any[]) => Promise<void> }
|
|
41
|
+
>;
|
|
38
42
|
ctx: any;
|
|
39
43
|
handlers: Map<string, ((...args: any[]) => Promise<any>)[]>;
|
|
40
44
|
fireSessionStart: () => Promise<void>;
|
|
@@ -45,6 +49,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
45
49
|
string,
|
|
46
50
|
{ execute: (...args: any[]) => Promise<any> }
|
|
47
51
|
>();
|
|
52
|
+
const commands = new Map<
|
|
53
|
+
string,
|
|
54
|
+
{ description?: string; handler: (...args: any[]) => Promise<void> }
|
|
55
|
+
>();
|
|
48
56
|
const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
|
|
49
57
|
const idle = opts.idle ?? true;
|
|
50
58
|
const ctx = {
|
|
@@ -64,6 +72,9 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
64
72
|
registerTool(def: any) {
|
|
65
73
|
tools.set(def.name, def);
|
|
66
74
|
},
|
|
75
|
+
registerCommand(name: string, def: any) {
|
|
76
|
+
commands.set(name, def);
|
|
77
|
+
},
|
|
67
78
|
on(event: string, handler: (...args: any[]) => Promise<any>) {
|
|
68
79
|
const list = handlers.get(event) ?? [];
|
|
69
80
|
list.push(handler);
|
|
@@ -75,7 +86,16 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
75
86
|
await h({ reason: "startup" }, ctx);
|
|
76
87
|
}
|
|
77
88
|
};
|
|
78
|
-
return {
|
|
89
|
+
return {
|
|
90
|
+
pi,
|
|
91
|
+
wakes,
|
|
92
|
+
entries,
|
|
93
|
+
tools,
|
|
94
|
+
commands,
|
|
95
|
+
ctx,
|
|
96
|
+
handlers,
|
|
97
|
+
fireSessionStart,
|
|
98
|
+
};
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
async function loadExtension(
|
|
@@ -234,6 +254,143 @@ test("bgtail: returns last N lines, strips the exit marker", async () => {
|
|
|
234
254
|
}
|
|
235
255
|
});
|
|
236
256
|
|
|
257
|
+
test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lines", async () => {
|
|
258
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
259
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
260
|
+
try {
|
|
261
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
262
|
+
await loadExtension(pi);
|
|
263
|
+
const bgrun = tools.get("bgrun")!;
|
|
264
|
+
const bgtail = tools.get("bgtail")!;
|
|
265
|
+
|
|
266
|
+
// 1 ANSI-colored line, 5 identical spinner lines, 1 huge line
|
|
267
|
+
const esc = "\u001b"; // literal ESC byte, safe to pass through a shell arg
|
|
268
|
+
const payload =
|
|
269
|
+
`printf "${esc}[32mOK green${esc}[0m\nwait\nwait\nwait\nwait\nwait\nline3\n"; ` +
|
|
270
|
+
"echo \"$(printf 'x%.0s' $(seq 1 5000))\"";
|
|
271
|
+
const res = await bgrun.execute(
|
|
272
|
+
"call-c1",
|
|
273
|
+
{ command: payload },
|
|
274
|
+
undefined,
|
|
275
|
+
undefined,
|
|
276
|
+
ctx,
|
|
277
|
+
);
|
|
278
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
279
|
+
await waitForWakes(wakes, 1);
|
|
280
|
+
|
|
281
|
+
const tail = await bgtail.execute(
|
|
282
|
+
"call-c1",
|
|
283
|
+
{ id, lines: 40 },
|
|
284
|
+
undefined,
|
|
285
|
+
undefined,
|
|
286
|
+
ctx,
|
|
287
|
+
);
|
|
288
|
+
const text = tail.content[0].text as string;
|
|
289
|
+
assert.ok(!text.includes("\u001b"), "ANSI escapes stripped");
|
|
290
|
+
assert.ok(text.includes("OK green"), "text after stripping survives");
|
|
291
|
+
assert.match(
|
|
292
|
+
text,
|
|
293
|
+
/wait {2}\[x5\]/,
|
|
294
|
+
"5 identical lines collapsed to one with count",
|
|
295
|
+
);
|
|
296
|
+
assert.ok(!text.includes("x".repeat(4000)), "5000-char line capped");
|
|
297
|
+
assert.match(text, /\u2026\[\+3\d{3} chars\]/, "truncation marker present");
|
|
298
|
+
assert.match(text, /\(\d+ ANSI escape/, "notes mention ANSI stripping");
|
|
299
|
+
assert.match(
|
|
300
|
+
text,
|
|
301
|
+
/1 repeated-line run collapsed/,
|
|
302
|
+
"notes mention run collapse",
|
|
303
|
+
);
|
|
304
|
+
assert.ok((tail.details as any).condensed === true);
|
|
305
|
+
} finally {
|
|
306
|
+
delete process.env.PI_BGRUN_DIR;
|
|
307
|
+
rmSync(dir, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("bgtail: raw=true skips condensing", async () => {
|
|
312
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
313
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
314
|
+
try {
|
|
315
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
316
|
+
await loadExtension(pi);
|
|
317
|
+
const bgrun = tools.get("bgrun")!;
|
|
318
|
+
const bgtail = tools.get("bgtail")!;
|
|
319
|
+
|
|
320
|
+
const esc = "\u001b";
|
|
321
|
+
const res = await bgrun.execute(
|
|
322
|
+
"call-c2",
|
|
323
|
+
{ command: `printf "${esc}[31mraw-red${esc}[0m\nwait\nwait\nwait\n"` },
|
|
324
|
+
undefined,
|
|
325
|
+
undefined,
|
|
326
|
+
ctx,
|
|
327
|
+
);
|
|
328
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
329
|
+
await waitForWakes(wakes, 1);
|
|
330
|
+
|
|
331
|
+
const tail = await bgtail.execute(
|
|
332
|
+
"call-c2",
|
|
333
|
+
{ id, raw: true },
|
|
334
|
+
undefined,
|
|
335
|
+
undefined,
|
|
336
|
+
ctx,
|
|
337
|
+
);
|
|
338
|
+
const text = tail.content[0].text as string;
|
|
339
|
+
assert.ok(text.includes("\u001b[31m"), "raw keeps ANSI escapes");
|
|
340
|
+
assert.ok(
|
|
341
|
+
text.includes("wait\nwait\nwait"),
|
|
342
|
+
"raw keeps repeated lines uncollapsed",
|
|
343
|
+
);
|
|
344
|
+
assert.ok((tail.details as any).condensed === false);
|
|
345
|
+
} finally {
|
|
346
|
+
delete process.env.PI_BGRUN_DIR;
|
|
347
|
+
rmSync(dir, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("bgtail: total cap kicks in on large output with guidance note", async () => {
|
|
352
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
353
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
354
|
+
try {
|
|
355
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
356
|
+
await loadExtension(pi);
|
|
357
|
+
const bgrun = tools.get("bgrun")!;
|
|
358
|
+
const bgtail = tools.get("bgtail")!;
|
|
359
|
+
|
|
360
|
+
// ~200 distinct lines x ~500 chars = ~100KB, well past the 8KB total cap
|
|
361
|
+
const cmd =
|
|
362
|
+
"for i in $(seq 1 200); do echo \"line-$i $(printf 'y%.0s' $(seq 1 500))\"; done";
|
|
363
|
+
const res = await bgrun.execute(
|
|
364
|
+
"call-c3",
|
|
365
|
+
{ command: cmd },
|
|
366
|
+
undefined,
|
|
367
|
+
undefined,
|
|
368
|
+
ctx,
|
|
369
|
+
);
|
|
370
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
371
|
+
await waitForWakes(wakes, 1);
|
|
372
|
+
|
|
373
|
+
const tail = await bgtail.execute(
|
|
374
|
+
"call-c3",
|
|
375
|
+
{ id, lines: 200 },
|
|
376
|
+
undefined,
|
|
377
|
+
undefined,
|
|
378
|
+
ctx,
|
|
379
|
+
);
|
|
380
|
+
const text = tail.content[0].text as string;
|
|
381
|
+
assert.ok(text.length < 10_000, "result capped well below raw size");
|
|
382
|
+
assert.match(
|
|
383
|
+
text,
|
|
384
|
+
/output capped at 8000 chars — 200 raw lines total/,
|
|
385
|
+
"cap note names the raw line count and suggests escalation paths",
|
|
386
|
+
);
|
|
387
|
+
assert.ok((tail.details as any).condenserNotes, "notes in details too");
|
|
388
|
+
} finally {
|
|
389
|
+
delete process.env.PI_BGRUN_DIR;
|
|
390
|
+
rmSync(dir, { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
237
394
|
test("bgstatus: shows running then done with exit code", async () => {
|
|
238
395
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
239
396
|
process.env.PI_BGRUN_DIR = dir;
|
|
@@ -541,7 +698,7 @@ test("bgrun: name is optional — behavior unchanged without it", async () => {
|
|
|
541
698
|
);
|
|
542
699
|
const text = res.content[0].text as string;
|
|
543
700
|
// No 'name:' line in the response.
|
|
544
|
-
assert.ok(!/^
|
|
701
|
+
assert.ok(!/^ {2}name:/m.test(text), "no name line when name omitted");
|
|
545
702
|
const id = (text.match(/^started: ([^\n]+)/) || [])[1];
|
|
546
703
|
assert.ok(
|
|
547
704
|
id.startsWith("echo-unnamed-job-"),
|
|
@@ -576,7 +733,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
|
|
|
576
733
|
ctx,
|
|
577
734
|
);
|
|
578
735
|
assert.ok(
|
|
579
|
-
!/^
|
|
736
|
+
!/^ {2}name:/m.test(res1.content[0].text as string),
|
|
580
737
|
"blank name ignored",
|
|
581
738
|
);
|
|
582
739
|
|
|
@@ -590,7 +747,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
|
|
|
590
747
|
ctx,
|
|
591
748
|
);
|
|
592
749
|
const text2 = res2.content[0].text as string;
|
|
593
|
-
const nameLine = (text2.match(/^
|
|
750
|
+
const nameLine = (text2.match(/^ {2}name: (.+)$/m) || [])[1];
|
|
594
751
|
assert.equal(nameLine.length, 80, "name truncated to 80 chars");
|
|
595
752
|
|
|
596
753
|
await waitForWakes(wakes, 2);
|
|
@@ -942,14 +1099,15 @@ test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
|
|
|
942
1099
|
}
|
|
943
1100
|
});
|
|
944
1101
|
|
|
945
|
-
test("bgclean: removes a FINISHED job's old log even when its id-pid is alive", async () => {
|
|
1102
|
+
test("bgclean all: removes a FINISHED job's old log even when its id-pid is alive", async () => {
|
|
946
1103
|
// Regression: exit marker must win over pid liveness. Old code checked
|
|
947
1104
|
// pid first, so any log whose id-pid happened to be a live process (e.g.
|
|
948
1105
|
// pi's own pid from the old id bug, or pid reuse) was kept forever.
|
|
949
1106
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
950
1107
|
process.env.PI_BGRUN_DIR = dir;
|
|
951
1108
|
try {
|
|
952
|
-
// Old finished log whose id-pid is THIS process (alive!) — must
|
|
1109
|
+
// Old finished foreign log whose id-pid is THIS process (alive!) — must
|
|
1110
|
+
// still be removed by an explicit global sweep.
|
|
953
1111
|
const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`);
|
|
954
1112
|
writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n");
|
|
955
1113
|
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
@@ -960,14 +1118,31 @@ test("bgclean: removes a FINISHED job's old log even when its id-pid is alive",
|
|
|
960
1118
|
await loadExtension(pi);
|
|
961
1119
|
const bgclean = tools.get("bgclean")!;
|
|
962
1120
|
|
|
1121
|
+
// Default scope: this session only — the foreign log is untouched.
|
|
1122
|
+
const scoped = await bgclean.execute(
|
|
1123
|
+
"call-stale-scoped",
|
|
1124
|
+
{ days: 7 },
|
|
1125
|
+
undefined,
|
|
1126
|
+
undefined,
|
|
1127
|
+
ctx,
|
|
1128
|
+
);
|
|
1129
|
+
assert.match(scoped.content[0].text as string, /removed 0/);
|
|
1130
|
+
assert.ok(
|
|
1131
|
+
existsSync(oldPath),
|
|
1132
|
+
"foreign log untouched by session-scoped bgclean",
|
|
1133
|
+
);
|
|
1134
|
+
|
|
963
1135
|
const result = await bgclean.execute(
|
|
964
1136
|
"call-stale",
|
|
965
|
-
{ days: 7 },
|
|
1137
|
+
{ days: 7, all: true },
|
|
966
1138
|
undefined,
|
|
967
1139
|
undefined,
|
|
968
1140
|
ctx,
|
|
969
1141
|
);
|
|
970
|
-
assert.match(
|
|
1142
|
+
assert.match(
|
|
1143
|
+
result.content[0].text as string,
|
|
1144
|
+
/removed 1 job log\(s\) \(all sessions\)/,
|
|
1145
|
+
);
|
|
971
1146
|
assert.ok(
|
|
972
1147
|
!existsSync(oldPath),
|
|
973
1148
|
"finished job's log removed despite live id-pid",
|
|
@@ -1008,10 +1183,267 @@ test("session_start adoption: skips finished jobs even with a live id-pid", asyn
|
|
|
1008
1183
|
}
|
|
1009
1184
|
});
|
|
1010
1185
|
|
|
1011
|
-
test("
|
|
1186
|
+
test("session_start: reconstructed 'running' job that finished while pi was down is cleared, not zombified", async () => {
|
|
1187
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1188
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1189
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1190
|
+
try {
|
|
1191
|
+
// A job from 5 days ago whose transcript entry never got a done entry
|
|
1192
|
+
// (pi wasn't running when it exited), whose log is long gone and whose
|
|
1193
|
+
// pid is definitely dead.
|
|
1194
|
+
const zombieId = `cd-old-project-make-test-${Date.now()}-99999999`;
|
|
1195
|
+
const logPath = join(dir, `${zombieId}.log`); // never created
|
|
1196
|
+
const priorEntries = [
|
|
1197
|
+
{
|
|
1198
|
+
type: "custom",
|
|
1199
|
+
customType: "bgrun-job",
|
|
1200
|
+
data: {
|
|
1201
|
+
id: zombieId,
|
|
1202
|
+
pid: 99999999,
|
|
1203
|
+
cmd: "cd /old/project && make test",
|
|
1204
|
+
name: undefined,
|
|
1205
|
+
started: Date.now() - 5 * 24 * 60 * 60 * 1000,
|
|
1206
|
+
logPath,
|
|
1207
|
+
state: "running",
|
|
1208
|
+
},
|
|
1209
|
+
},
|
|
1210
|
+
];
|
|
1211
|
+
const { pi, entries, tools, ctx, fireSessionStart } = makeFakePi({
|
|
1212
|
+
priorEntries,
|
|
1213
|
+
});
|
|
1214
|
+
ctx.hasUI = true;
|
|
1215
|
+
const widgetCalls: (string[] | undefined)[] = [];
|
|
1216
|
+
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
1217
|
+
widgetCalls.push(lines);
|
|
1218
|
+
|
|
1219
|
+
await loadExtension(pi);
|
|
1220
|
+
await fireSessionStart();
|
|
1221
|
+
|
|
1222
|
+
// Revalidation runs before the widget ever renders — the zombie is
|
|
1223
|
+
// cleared immediately instead of showing as "running" forever.
|
|
1224
|
+
assert.ok(
|
|
1225
|
+
!widgetCalls.some((l) => Array.isArray(l)),
|
|
1226
|
+
"reconstructed zombie never shown in the widget",
|
|
1227
|
+
);
|
|
1228
|
+
|
|
1229
|
+
// A done entry is appended so future resumes reconstruct it as done.
|
|
1230
|
+
const doneEntry = entries.find(
|
|
1231
|
+
(e) =>
|
|
1232
|
+
e.customType === "bgrun-job" &&
|
|
1233
|
+
e.data?.id === zombieId &&
|
|
1234
|
+
e.data?.state === "done",
|
|
1235
|
+
);
|
|
1236
|
+
assert.ok(doneEntry, "done entry appended for the recovered job");
|
|
1237
|
+
|
|
1238
|
+
// Single-id lookup reports done, not running.
|
|
1239
|
+
const bgstatus = tools.get("bgstatus")!;
|
|
1240
|
+
const res = await bgstatus.execute(
|
|
1241
|
+
"call-z1",
|
|
1242
|
+
{ id: zombieId },
|
|
1243
|
+
undefined,
|
|
1244
|
+
undefined,
|
|
1245
|
+
ctx,
|
|
1246
|
+
);
|
|
1247
|
+
assert.match(res.content[0].text as string, /: done/);
|
|
1248
|
+
} finally {
|
|
1249
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1250
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test("session_start: done entries with missing exitCode (signal kills) reconstruct as done", async () => {
|
|
1255
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1256
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1257
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1258
|
+
try {
|
|
1259
|
+
// Jobs killed by a signal persist state:"done" with exitCode: undefined —
|
|
1260
|
+
// reconstruction must honor the state field, not just the exit code.
|
|
1261
|
+
const killedId = `nightly-watch-${Date.now()}-${process.pid}`;
|
|
1262
|
+
const logPath = join(dir, `${killedId}.log`);
|
|
1263
|
+
writeFileSync(logPath, "partial output\n"); // no marker — killed before it
|
|
1264
|
+
const priorEntries = [
|
|
1265
|
+
{
|
|
1266
|
+
type: "custom",
|
|
1267
|
+
customType: "bgrun-job",
|
|
1268
|
+
data: {
|
|
1269
|
+
id: killedId,
|
|
1270
|
+
pid: process.pid, // alive — liveness alone must not resurrect it as running
|
|
1271
|
+
cmd: "npm run watch",
|
|
1272
|
+
name: "nightly-watch",
|
|
1273
|
+
started: Date.now() - 60_000,
|
|
1274
|
+
logPath,
|
|
1275
|
+
state: "done",
|
|
1276
|
+
exitCode: undefined,
|
|
1277
|
+
exitedAt: Date.now() - 30_000,
|
|
1278
|
+
},
|
|
1279
|
+
},
|
|
1280
|
+
];
|
|
1281
|
+
const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1282
|
+
ctx.hasUI = true;
|
|
1283
|
+
const widgetCalls: (string[] | undefined)[] = [];
|
|
1284
|
+
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
1285
|
+
widgetCalls.push(lines);
|
|
1286
|
+
|
|
1287
|
+
await loadExtension(pi);
|
|
1288
|
+
await fireSessionStart();
|
|
1289
|
+
|
|
1290
|
+
assert.ok(
|
|
1291
|
+
!widgetCalls.some((l) => Array.isArray(l)),
|
|
1292
|
+
"signal-killed job with a done entry is not resurrected as running",
|
|
1293
|
+
);
|
|
1294
|
+
const bgstatus = tools.get("bgstatus")!;
|
|
1295
|
+
const res = await bgstatus.execute(
|
|
1296
|
+
"call-z2",
|
|
1297
|
+
{ id: killedId },
|
|
1298
|
+
undefined,
|
|
1299
|
+
undefined,
|
|
1300
|
+
ctx,
|
|
1301
|
+
);
|
|
1302
|
+
assert.match(res.content[0].text as string, /: done/);
|
|
1303
|
+
} finally {
|
|
1304
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1305
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("auto-clean: session boundaries sweep this session's old logs AND week-old foreign orphans by default", async () => {
|
|
1310
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1311
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1312
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1313
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1314
|
+
try {
|
|
1315
|
+
const fs = await import("node:fs");
|
|
1316
|
+
const backdate = (path: string) => {
|
|
1317
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1318
|
+
fs.utimesSync(path, oldTime, oldTime);
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
// This session's old done job (from the transcript) with a backdated log.
|
|
1322
|
+
const mineId = `my-old-job-${Date.now()}-99999999`;
|
|
1323
|
+
const myLog = join(dir, `${mineId}.log`);
|
|
1324
|
+
fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
|
|
1325
|
+
backdate(myLog);
|
|
1326
|
+
|
|
1327
|
+
// A foreign session's week-old FINISHED log — an orphan; swept by default.
|
|
1328
|
+
const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
|
|
1329
|
+
fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1330
|
+
backdate(orphanLog);
|
|
1331
|
+
|
|
1332
|
+
// A foreign session's RECENT finished log — within retention, kept.
|
|
1333
|
+
const recentForeignLog = join(
|
|
1334
|
+
dir,
|
|
1335
|
+
`foreign-recent-${Math.floor(Date.now() / 1000)}-99997.log`,
|
|
1336
|
+
);
|
|
1337
|
+
fs.writeFileSync(recentForeignLog, "recent foreign\n__BGRUN_EXIT__=0\n");
|
|
1338
|
+
|
|
1339
|
+
// A foreign session's week-old RUNNING log (no marker, live pid) — running
|
|
1340
|
+
// jobs are pid-protected even when old.
|
|
1341
|
+
const runningForeignLog = join(
|
|
1342
|
+
dir,
|
|
1343
|
+
`foreign-running-${Math.floor(Date.now() / 1000)}-${process.pid}.log`,
|
|
1344
|
+
);
|
|
1345
|
+
fs.writeFileSync(runningForeignLog, "still going\n");
|
|
1346
|
+
backdate(runningForeignLog);
|
|
1347
|
+
|
|
1348
|
+
const priorEntries = [
|
|
1349
|
+
{
|
|
1350
|
+
type: "custom",
|
|
1351
|
+
customType: "bgrun-job",
|
|
1352
|
+
data: {
|
|
1353
|
+
id: mineId,
|
|
1354
|
+
pid: 99999999,
|
|
1355
|
+
cmd: "echo mine",
|
|
1356
|
+
name: undefined,
|
|
1357
|
+
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1358
|
+
logPath: myLog,
|
|
1359
|
+
state: "done",
|
|
1360
|
+
exitCode: 0,
|
|
1361
|
+
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
];
|
|
1365
|
+
const { pi, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1366
|
+
await loadExtension(pi);
|
|
1367
|
+
await fireSessionStart();
|
|
1368
|
+
|
|
1369
|
+
assert.ok(!fs.existsSync(myLog), "this session's old log swept");
|
|
1370
|
+
assert.ok(
|
|
1371
|
+
!fs.existsSync(orphanLog),
|
|
1372
|
+
"week-old finished foreign orphan swept by default",
|
|
1373
|
+
);
|
|
1374
|
+
assert.ok(
|
|
1375
|
+
fs.existsSync(recentForeignLog),
|
|
1376
|
+
"recent foreign log kept (within retention)",
|
|
1377
|
+
);
|
|
1378
|
+
assert.ok(
|
|
1379
|
+
fs.existsSync(runningForeignLog),
|
|
1380
|
+
"old but RUNNING foreign log kept (pid-protected)",
|
|
1381
|
+
);
|
|
1382
|
+
} finally {
|
|
1383
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1384
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
test("auto-clean: globalAutoClean=false opts out — foreign orphans untouched, own old logs still swept", async () => {
|
|
1389
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1390
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1391
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1392
|
+
process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
|
|
1393
|
+
try {
|
|
1394
|
+
const fs = await import("node:fs");
|
|
1395
|
+
const backdate = (path: string) => {
|
|
1396
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1397
|
+
fs.utimesSync(path, oldTime, oldTime);
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const mineId = `my-old-job-${Date.now()}-99999999`;
|
|
1401
|
+
const myLog = join(dir, `${mineId}.log`);
|
|
1402
|
+
fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
|
|
1403
|
+
backdate(myLog);
|
|
1404
|
+
|
|
1405
|
+
const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
|
|
1406
|
+
fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1407
|
+
backdate(orphanLog);
|
|
1408
|
+
|
|
1409
|
+
const priorEntries = [
|
|
1410
|
+
{
|
|
1411
|
+
type: "custom",
|
|
1412
|
+
customType: "bgrun-job",
|
|
1413
|
+
data: {
|
|
1414
|
+
id: mineId,
|
|
1415
|
+
pid: 99999999,
|
|
1416
|
+
cmd: "echo mine",
|
|
1417
|
+
name: undefined,
|
|
1418
|
+
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1419
|
+
logPath: myLog,
|
|
1420
|
+
state: "done",
|
|
1421
|
+
exitCode: 0,
|
|
1422
|
+
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1423
|
+
},
|
|
1424
|
+
},
|
|
1425
|
+
];
|
|
1426
|
+
const { pi, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1427
|
+
await loadExtension(pi);
|
|
1428
|
+
await fireSessionStart();
|
|
1429
|
+
|
|
1430
|
+
assert.ok(!fs.existsSync(myLog), "this session's old log still swept");
|
|
1431
|
+
assert.ok(
|
|
1432
|
+
fs.existsSync(orphanLog),
|
|
1433
|
+
"foreign orphan untouched when globalAutoClean is off",
|
|
1434
|
+
);
|
|
1435
|
+
} finally {
|
|
1436
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1437
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1438
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
test("auto-clean: global orphan sweep is throttled via .last-clean; manual bgclean all always runs", async () => {
|
|
1012
1443
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1013
1444
|
process.env.PI_BGRUN_DIR = dir;
|
|
1014
1445
|
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1446
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; // default: on
|
|
1015
1447
|
try {
|
|
1016
1448
|
const fs = await import("node:fs");
|
|
1017
1449
|
const backdate = (path: string) => {
|
|
@@ -1019,92 +1451,150 @@ test("auto-clean: throttled via .last-clean marker; manual bgclean always runs",
|
|
|
1019
1451
|
fs.utimesSync(path, oldTime, oldTime);
|
|
1020
1452
|
};
|
|
1021
1453
|
|
|
1022
|
-
// Old log A + first session_start (no marker yet) → sweep
|
|
1454
|
+
// Old foreign log A + first session_start (no marker yet) → global sweep
|
|
1455
|
+
// runs, A removed.
|
|
1023
1456
|
const logA = join(dir, "old-a-1000000000-99999.log");
|
|
1024
1457
|
fs.writeFileSync(logA, "old a\n__BGRUN_EXIT__=0\n");
|
|
1025
1458
|
backdate(logA);
|
|
1026
1459
|
{
|
|
1027
|
-
const { pi,
|
|
1460
|
+
const { pi, fireSessionStart } = makeFakePi();
|
|
1028
1461
|
await loadExtension(pi);
|
|
1029
1462
|
await fireSessionStart();
|
|
1030
1463
|
}
|
|
1031
|
-
assert.ok(!fs.existsSync(logA), "first sweep removed old log A");
|
|
1464
|
+
assert.ok(!fs.existsSync(logA), "first global sweep removed old log A");
|
|
1032
1465
|
assert.ok(
|
|
1033
1466
|
fs.existsSync(join(dir, ".last-clean")),
|
|
1034
1467
|
"throttle marker written",
|
|
1035
1468
|
);
|
|
1036
1469
|
|
|
1037
|
-
// Old log B + second session_start while marker is fresh →
|
|
1470
|
+
// Old foreign log B + second session_start while marker is fresh →
|
|
1471
|
+
// throttled, B kept.
|
|
1038
1472
|
const logB = join(dir, "old-b-1000000000-99998.log");
|
|
1039
1473
|
fs.writeFileSync(logB, "old b\n__BGRUN_EXIT__=0\n");
|
|
1040
1474
|
backdate(logB);
|
|
1041
1475
|
{
|
|
1042
|
-
const { pi,
|
|
1476
|
+
const { pi, fireSessionStart } = makeFakePi();
|
|
1043
1477
|
await loadExtension(pi);
|
|
1044
1478
|
await fireSessionStart();
|
|
1045
1479
|
}
|
|
1046
|
-
assert.ok(
|
|
1480
|
+
assert.ok(
|
|
1481
|
+
fs.existsSync(logB),
|
|
1482
|
+
"second global sweep throttled — old log B kept",
|
|
1483
|
+
);
|
|
1047
1484
|
|
|
1048
|
-
// Manual bgclean ignores the throttle and removes B.
|
|
1485
|
+
// Manual `bgclean all` ignores the throttle and removes B.
|
|
1049
1486
|
const { pi: pi3, tools: tools3, ctx: ctx3 } = makeFakePi();
|
|
1050
1487
|
await loadExtension(pi3);
|
|
1051
1488
|
const bgclean = tools3.get("bgclean")!;
|
|
1052
1489
|
const result = await bgclean.execute(
|
|
1053
1490
|
"call-t1",
|
|
1054
|
-
{},
|
|
1491
|
+
{ all: true },
|
|
1055
1492
|
undefined,
|
|
1056
1493
|
undefined,
|
|
1057
1494
|
ctx3,
|
|
1058
1495
|
);
|
|
1059
|
-
assert.match(
|
|
1060
|
-
|
|
1496
|
+
assert.match(
|
|
1497
|
+
result.content[0].text as string,
|
|
1498
|
+
/removed 1 job log\(s\) \(all sessions\)/,
|
|
1499
|
+
);
|
|
1500
|
+
assert.ok(!fs.existsSync(logB), "manual bgclean all removed log B");
|
|
1061
1501
|
} finally {
|
|
1062
1502
|
delete process.env.PI_BGRUN_DIR;
|
|
1503
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1063
1504
|
rmSync(dir, { recursive: true, force: true });
|
|
1064
1505
|
}
|
|
1065
1506
|
});
|
|
1066
1507
|
|
|
1067
|
-
test("bgclean:
|
|
1508
|
+
test("bgclean: default scope is this session's logs; all: true sweeps everything", async () => {
|
|
1068
1509
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1069
1510
|
process.env.PI_BGRUN_DIR = dir;
|
|
1511
|
+
// Isolate bgclean's scoping from the global orphan auto-sweep (default on)
|
|
1512
|
+
// so the foreign log survives session_start for bgclean to (not) act on.
|
|
1513
|
+
process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
|
|
1070
1514
|
try {
|
|
1071
|
-
const
|
|
1515
|
+
const fs = await import("node:fs");
|
|
1516
|
+
const backdate = (path: string) => {
|
|
1517
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1518
|
+
fs.utimesSync(path, oldTime, oldTime);
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
const mkEntry = (
|
|
1522
|
+
id: string,
|
|
1523
|
+
logPath: string,
|
|
1524
|
+
extra: Record<string, unknown> = {},
|
|
1525
|
+
) => ({
|
|
1526
|
+
type: "custom",
|
|
1527
|
+
customType: "bgrun-job",
|
|
1528
|
+
data: {
|
|
1529
|
+
id,
|
|
1530
|
+
pid: 99999999,
|
|
1531
|
+
cmd: `echo ${id}`,
|
|
1532
|
+
name: undefined,
|
|
1533
|
+
started: Date.now() - 60_000,
|
|
1534
|
+
logPath,
|
|
1535
|
+
state: "done",
|
|
1536
|
+
exitCode: 0,
|
|
1537
|
+
exitedAt: Date.now() - 30_000,
|
|
1538
|
+
...extra,
|
|
1539
|
+
},
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
// This session's recent done job (fresh log — kept).
|
|
1543
|
+
const recentId = `recent-job-${Date.now()}-99999998`;
|
|
1544
|
+
const recentLog = join(dir, `${recentId}.log`);
|
|
1545
|
+
fs.writeFileSync(recentLog, "recent\n__BGRUN_EXIT__=0\n");
|
|
1546
|
+
|
|
1547
|
+
// This session's old done job (backdated log — removed by default scope).
|
|
1548
|
+
const oldId = `old-session-job-${Date.now()}-99999997`;
|
|
1549
|
+
const oldLog = join(dir, `${oldId}.log`);
|
|
1550
|
+
fs.writeFileSync(oldLog, "old session job\n__BGRUN_EXIT__=0\n");
|
|
1551
|
+
backdate(oldLog);
|
|
1552
|
+
|
|
1553
|
+
// A foreign session's old log — untouched by default, removed with all.
|
|
1554
|
+
const foreignLog = join(dir, "foreign-old-job-1000000000-99996.log");
|
|
1555
|
+
fs.writeFileSync(foreignLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1556
|
+
backdate(foreignLog);
|
|
1557
|
+
|
|
1558
|
+
const priorEntries = [
|
|
1559
|
+
mkEntry(recentId, recentLog),
|
|
1560
|
+
mkEntry(oldId, oldLog, {
|
|
1561
|
+
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1562
|
+
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1563
|
+
}),
|
|
1564
|
+
];
|
|
1565
|
+
const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1072
1566
|
await loadExtension(pi);
|
|
1073
|
-
|
|
1567
|
+
await fireSessionStart(); // reconstruct + session-scoped auto-sweep runs here too
|
|
1568
|
+
|
|
1074
1569
|
const bgclean = tools.get("bgclean")!;
|
|
1075
1570
|
|
|
1076
|
-
//
|
|
1077
|
-
await
|
|
1078
|
-
"call-
|
|
1079
|
-
{
|
|
1571
|
+
// Default: this session only.
|
|
1572
|
+
const scoped = await bgclean.execute(
|
|
1573
|
+
"call-c2",
|
|
1574
|
+
{ days: 7 },
|
|
1080
1575
|
undefined,
|
|
1081
1576
|
undefined,
|
|
1082
1577
|
ctx,
|
|
1083
1578
|
);
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
fs.utimesSync(oldPath, oldTime, oldTime);
|
|
1579
|
+
assert.match(scoped.content[0].text as string, /\(this session\)/);
|
|
1580
|
+
assert.ok(!fs.existsSync(oldLog), "this session's old log removed");
|
|
1581
|
+
assert.ok(fs.existsSync(recentLog), "this session's recent log kept");
|
|
1582
|
+
assert.ok(
|
|
1583
|
+
fs.existsSync(foreignLog),
|
|
1584
|
+
"foreign log untouched by session-scoped bgclean",
|
|
1585
|
+
);
|
|
1092
1586
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1587
|
+
// all: true sweeps the shared dir.
|
|
1588
|
+
const global = await bgclean.execute(
|
|
1589
|
+
"call-c3",
|
|
1590
|
+
{ days: 7, all: true },
|
|
1096
1591
|
undefined,
|
|
1097
1592
|
undefined,
|
|
1098
1593
|
ctx,
|
|
1099
1594
|
);
|
|
1100
|
-
|
|
1101
|
-
assert.
|
|
1102
|
-
assert.ok(
|
|
1103
|
-
// The recent log should still exist.
|
|
1104
|
-
const remaining = fs
|
|
1105
|
-
.readdirSync(dir)
|
|
1106
|
-
.filter((f: string) => f.endsWith(".log"));
|
|
1107
|
-
assert.equal(remaining.length, 1, "recent log kept");
|
|
1595
|
+
assert.match(global.content[0].text as string, /\(all sessions\)/);
|
|
1596
|
+
assert.ok(!fs.existsSync(foreignLog), "foreign log removed by bgclean all");
|
|
1597
|
+
assert.ok(fs.existsSync(recentLog), "recent log still kept");
|
|
1108
1598
|
} finally {
|
|
1109
1599
|
delete process.env.PI_BGRUN_DIR;
|
|
1110
1600
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -1168,6 +1658,115 @@ test("bgclean: does not remove a running job's log", async () => {
|
|
|
1168
1658
|
}
|
|
1169
1659
|
} finally {
|
|
1170
1660
|
delete process.env.PI_BGRUN_DIR;
|
|
1661
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1171
1662
|
rmSync(dir, { recursive: true, force: true });
|
|
1172
1663
|
}
|
|
1173
1664
|
});
|
|
1665
|
+
|
|
1666
|
+
test("slash commands: /bgstatus, /bgtail, /bgclean registered and share the tool logic", async () => {
|
|
1667
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1668
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1669
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1670
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1671
|
+
try {
|
|
1672
|
+
const { pi, wakes, tools, commands, ctx } = makeFakePi();
|
|
1673
|
+
ctx.hasUI = true;
|
|
1674
|
+
const notes: { text: string; kind: string }[] = [];
|
|
1675
|
+
ctx.ui.notify = (text: string, kind: string) => notes.push({ text, kind });
|
|
1676
|
+
|
|
1677
|
+
await loadExtension(pi);
|
|
1678
|
+
|
|
1679
|
+
// All three human-facing commands are registered (/bgrun is agent-only).
|
|
1680
|
+
assert.ok(commands.has("bgstatus"), "/bgstatus registered");
|
|
1681
|
+
assert.ok(commands.has("bgtail"), "/bgtail registered");
|
|
1682
|
+
assert.ok(commands.has("bgclean"), "/bgclean registered");
|
|
1683
|
+
assert.ok(!commands.has("bgrun"), "/bgrun deliberately not a command");
|
|
1684
|
+
|
|
1685
|
+
// Run a real job to completion so there's something to inspect.
|
|
1686
|
+
const bgrun = tools.get("bgrun")!;
|
|
1687
|
+
const res = await bgrun.execute(
|
|
1688
|
+
"call-cmd1",
|
|
1689
|
+
{ command: "echo cmd-mirror", name: "mirror-job" },
|
|
1690
|
+
undefined,
|
|
1691
|
+
undefined,
|
|
1692
|
+
ctx,
|
|
1693
|
+
);
|
|
1694
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
1695
|
+
await waitForWakes(wakes, 1);
|
|
1696
|
+
|
|
1697
|
+
// /bgstatus <id> → single-job status via notify.
|
|
1698
|
+
await commands.get("bgstatus")!.handler(id, ctx);
|
|
1699
|
+
assert.ok(
|
|
1700
|
+
notes.some((n) => n.text.includes(id) && /: done/.test(n.text)),
|
|
1701
|
+
"/bgstatus <id> notifies job status",
|
|
1702
|
+
);
|
|
1703
|
+
|
|
1704
|
+
// /bgstatus done → listing includes the finished job.
|
|
1705
|
+
await commands.get("bgstatus")!.handler("done", ctx);
|
|
1706
|
+
assert.ok(
|
|
1707
|
+
notes.some((n) => /mirror-job: done exit=0/.test(n.text)),
|
|
1708
|
+
"/bgstatus done lists finished jobs",
|
|
1709
|
+
);
|
|
1710
|
+
|
|
1711
|
+
// /bgtail <id> <lines> → condensed tail via notify.
|
|
1712
|
+
await commands.get("bgtail")!.handler(`${id} 5`, ctx);
|
|
1713
|
+
assert.ok(
|
|
1714
|
+
notes.some((n) => n.text.includes("cmd-mirror")),
|
|
1715
|
+
"/bgtail notifies the log tail",
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
// /bgtail with no args → usage error.
|
|
1719
|
+
await commands.get("bgtail")!.handler("", ctx);
|
|
1720
|
+
assert.ok(
|
|
1721
|
+
notes.some((n) => n.kind === "error" && /Usage: \/bgtail/.test(n.text)),
|
|
1722
|
+
"/bgtail without id shows usage",
|
|
1723
|
+
);
|
|
1724
|
+
|
|
1725
|
+
// /bgclean (no args) → session-scoped summary via notify.
|
|
1726
|
+
await commands.get("bgclean")!.handler("", ctx);
|
|
1727
|
+
assert.ok(
|
|
1728
|
+
notes.some((n) => /removed 0 job log\(s\) \(this session\)/.test(n.text)),
|
|
1729
|
+
"/bgclean notifies the session-scoped summary",
|
|
1730
|
+
);
|
|
1731
|
+
|
|
1732
|
+
// /bgclean 7 all → global scope.
|
|
1733
|
+
await commands.get("bgclean")!.handler("7 all", ctx);
|
|
1734
|
+
assert.ok(
|
|
1735
|
+
notes.some((n) => /\(all sessions\)/.test(n.text)),
|
|
1736
|
+
"/bgclean all notifies the global summary",
|
|
1737
|
+
);
|
|
1738
|
+
} finally {
|
|
1739
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1740
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
test("formatSince: same-day shows time only; older days include the date", async () => {
|
|
1745
|
+
const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
|
|
1746
|
+
const mod: any = await import(url);
|
|
1747
|
+
assert.equal(typeof mod.formatSince, "function");
|
|
1748
|
+
|
|
1749
|
+
const now = new Date("2026-09-09T10:00:00").getTime();
|
|
1750
|
+
const sameDay = new Date("2026-09-09T06:30:12").getTime();
|
|
1751
|
+
const prevDay = new Date("2026-09-04T15:05:40").getTime();
|
|
1752
|
+
const prevMonth = new Date("2026-08-12T23:59:59").getTime();
|
|
1753
|
+
const prevYear = new Date("2025-12-30T08:00:00").getTime();
|
|
1754
|
+
|
|
1755
|
+
// Same calendar day → time only (unchanged display).
|
|
1756
|
+
assert.equal(mod.formatSince(sameDay, now), "06:30:12");
|
|
1757
|
+
|
|
1758
|
+
// Different day, same year → date + time.
|
|
1759
|
+
const prevDayStr = mod.formatSince(prevDay, now);
|
|
1760
|
+
assert.match(prevDayStr, /Sep 4/);
|
|
1761
|
+
assert.match(prevDayStr, /15:05:40/);
|
|
1762
|
+
|
|
1763
|
+
const prevMonthStr = mod.formatSince(prevMonth, now);
|
|
1764
|
+
assert.match(prevMonthStr, /Aug 12/);
|
|
1765
|
+
assert.match(prevMonthStr, /23:59:59/);
|
|
1766
|
+
|
|
1767
|
+
// Different year → date includes the year.
|
|
1768
|
+
const prevYearStr = mod.formatSince(prevYear, now);
|
|
1769
|
+
assert.match(prevYearStr, /2025/);
|
|
1770
|
+
assert.match(prevYearStr, /Dec 30/);
|
|
1771
|
+
assert.match(prevYearStr, /08:00:00/);
|
|
1772
|
+
});
|