@checkstack/dev-server 0.2.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.
@@ -0,0 +1,506 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import path from "node:path";
3
+ import {
4
+ parseDevArgs,
5
+ validatePluginPackageJson,
6
+ resolveBackendEntry,
7
+ shouldSpawnFrontend,
8
+ buildBackendChildEnv,
9
+ createDebouncedWatcher,
10
+ type TimerHandle,
11
+ } from "./dev-internals";
12
+
13
+ // ─────────────────────────────────────────────────────────────────────
14
+ // parseDevArgs
15
+ // ─────────────────────────────────────────────────────────────────────
16
+
17
+ describe("parseDevArgs", () => {
18
+ it("uses sensible defaults when no flags or env are set", () => {
19
+ const args = parseDevArgs({ raw: [], env: {}, cwd: "/some/where" });
20
+ expect(args).toEqual({
21
+ cwd: "/some/where",
22
+ port: 3000,
23
+ frontendPort: 5173,
24
+ databaseUrl:
25
+ "postgresql://checkstack:checkstack@localhost:5432/checkstack",
26
+ watch: true,
27
+ showHelp: false,
28
+ });
29
+ });
30
+
31
+ it("reads PORT, FRONTEND_PORT, DATABASE_URL from env when present", () => {
32
+ const args = parseDevArgs({
33
+ raw: [],
34
+ env: {
35
+ PORT: "4001",
36
+ FRONTEND_PORT: "5500",
37
+ DATABASE_URL: "postgresql://other:host/db",
38
+ },
39
+ cwd: "/x",
40
+ });
41
+ expect(args.port).toBe(4001);
42
+ expect(args.frontendPort).toBe(5500);
43
+ expect(args.databaseUrl).toBe("postgresql://other:host/db");
44
+ });
45
+
46
+ it("CLI flags override env defaults", () => {
47
+ const args = parseDevArgs({
48
+ raw: [
49
+ "--port",
50
+ "4002",
51
+ "--frontend-port",
52
+ "5174",
53
+ "--db-url",
54
+ "postgres://override",
55
+ ],
56
+ env: {
57
+ PORT: "4001",
58
+ FRONTEND_PORT: "5500",
59
+ DATABASE_URL: "postgresql://other:host/db",
60
+ },
61
+ cwd: "/x",
62
+ });
63
+ expect(args.port).toBe(4002);
64
+ expect(args.frontendPort).toBe(5174);
65
+ expect(args.databaseUrl).toBe("postgres://override");
66
+ });
67
+
68
+ it("--cwd resolves relative paths against process.cwd()", () => {
69
+ const args = parseDevArgs({
70
+ raw: ["--cwd", "./plugin"],
71
+ env: {},
72
+ cwd: "/initial",
73
+ });
74
+ // path.resolve uses real cwd; just verify --cwd flag was honored
75
+ expect(path.isAbsolute(args.cwd)).toBe(true);
76
+ expect(args.cwd.endsWith("/plugin")).toBe(true);
77
+ });
78
+
79
+ it("--no-watch disables watching", () => {
80
+ const args = parseDevArgs({ raw: ["--no-watch"], env: {}, cwd: "/x" });
81
+ expect(args.watch).toBe(false);
82
+ });
83
+
84
+ it("--help and -h both flip showHelp", () => {
85
+ expect(
86
+ parseDevArgs({ raw: ["--help"], env: {}, cwd: "/x" }).showHelp,
87
+ ).toBe(true);
88
+ expect(parseDevArgs({ raw: ["-h"], env: {}, cwd: "/x" }).showHelp).toBe(
89
+ true,
90
+ );
91
+ });
92
+
93
+ it("ignores unknown flags rather than throwing", () => {
94
+ const args = parseDevArgs({
95
+ raw: ["--unknown", "--port", "9000"],
96
+ env: {},
97
+ cwd: "/x",
98
+ });
99
+ expect(args.port).toBe(9000);
100
+ });
101
+ });
102
+
103
+ // ─────────────────────────────────────────────────────────────────────
104
+ // validatePluginPackageJson
105
+ // ─────────────────────────────────────────────────────────────────────
106
+
107
+ describe("validatePluginPackageJson", () => {
108
+ const validPkg = {
109
+ name: "@my-org/widget-backend",
110
+ version: "1.0.0",
111
+ description: "Test plugin",
112
+ author: "Tester",
113
+ license: "Elastic-2.0",
114
+ checkstack: { type: "backend", pluginId: "widget" },
115
+ };
116
+
117
+ it("returns ok with parsed metadata for a valid package.json", () => {
118
+ const result = validatePluginPackageJson({
119
+ cwd: "/cwd",
120
+ readFile: () => JSON.stringify(validPkg),
121
+ exists: () => true,
122
+ });
123
+ expect(result.ok).toBe(true);
124
+ if (result.ok) {
125
+ expect(result.metadata.name).toBe("@my-org/widget-backend");
126
+ expect(result.metadata.checkstack.pluginId).toBe("widget");
127
+ }
128
+ });
129
+
130
+ it("returns missingPackageJson when no package.json on disk", () => {
131
+ const result = validatePluginPackageJson({
132
+ cwd: "/cwd",
133
+ readFile: () => {
134
+ throw new Error("should not be called");
135
+ },
136
+ exists: () => false,
137
+ });
138
+ expect(result.ok).toBe(false);
139
+ if (!result.ok) {
140
+ expect("missingPackageJson" in result).toBe(true);
141
+ }
142
+ });
143
+
144
+ it("returns issues with field paths when validation fails", () => {
145
+ // Strip required fields
146
+ const broken = { ...validPkg, description: "", license: "" };
147
+ const result = validatePluginPackageJson({
148
+ cwd: "/cwd",
149
+ readFile: () => JSON.stringify(broken),
150
+ exists: () => true,
151
+ });
152
+ expect(result.ok).toBe(false);
153
+ if (!result.ok && "issues" in result) {
154
+ const all = result.issues.join("\n");
155
+ expect(all).toContain("description");
156
+ expect(all).toContain("license");
157
+ }
158
+ });
159
+
160
+ it("issues field paths are dot-joined and not empty", () => {
161
+ const missingCheckstack = {
162
+ ...validPkg,
163
+ checkstack: { type: "backend" }, // pluginId missing → wait, pluginId is optional
164
+ };
165
+ // Force a real failure: invalid type
166
+ const trulyBroken = {
167
+ ...validPkg,
168
+ checkstack: { type: "invalid-type" },
169
+ };
170
+ void missingCheckstack;
171
+ const result = validatePluginPackageJson({
172
+ cwd: "/cwd",
173
+ readFile: () => JSON.stringify(trulyBroken),
174
+ exists: () => true,
175
+ });
176
+ expect(result.ok).toBe(false);
177
+ if (!result.ok && "issues" in result) {
178
+ expect(result.issues.length).toBeGreaterThan(0);
179
+ for (const issue of result.issues) {
180
+ expect(issue.includes(":")).toBe(true);
181
+ }
182
+ }
183
+ });
184
+ });
185
+
186
+ // ─────────────────────────────────────────────────────────────────────
187
+ // resolveBackendEntry
188
+ // ─────────────────────────────────────────────────────────────────────
189
+
190
+ describe("resolveBackendEntry", () => {
191
+ it("resolves to <pkgDir>/<main> when @checkstack/backend is installed", () => {
192
+ const pkgJsonPath = "/cwd/node_modules/@checkstack/backend/package.json";
193
+ const entry = resolveBackendEntry({
194
+ fromCwd: "/cwd",
195
+ resolveFrom: (_, req) =>
196
+ req === "@checkstack/backend/package.json" ? pkgJsonPath : undefined,
197
+ readFile: () =>
198
+ JSON.stringify({
199
+ name: "@checkstack/backend",
200
+ main: "src/index.ts",
201
+ }),
202
+ });
203
+ expect(entry).toBe("/cwd/node_modules/@checkstack/backend/src/index.ts");
204
+ });
205
+
206
+ it("falls back to src/index.ts when no main field", () => {
207
+ const entry = resolveBackendEntry({
208
+ fromCwd: "/cwd",
209
+ resolveFrom: () => "/x/package.json",
210
+ readFile: () => JSON.stringify({ name: "@checkstack/backend" }),
211
+ });
212
+ expect(entry).toBe("/x/src/index.ts");
213
+ });
214
+
215
+ it("returns undefined when @checkstack/backend isn't installed", () => {
216
+ const entry = resolveBackendEntry({
217
+ fromCwd: "/cwd",
218
+ resolveFrom: () => undefined,
219
+ });
220
+ expect(entry).toBeUndefined();
221
+ });
222
+
223
+ it("returns undefined when package.json fails to parse", () => {
224
+ const entry = resolveBackendEntry({
225
+ fromCwd: "/cwd",
226
+ resolveFrom: () => "/x/package.json",
227
+ readFile: () => "not-json",
228
+ });
229
+ expect(entry).toBeUndefined();
230
+ });
231
+ });
232
+
233
+ // ─────────────────────────────────────────────────────────────────────
234
+ // shouldSpawnFrontend
235
+ // ─────────────────────────────────────────────────────────────────────
236
+
237
+ describe("shouldSpawnFrontend", () => {
238
+ it("returns true for a frontend-type plugin", () => {
239
+ expect(
240
+ shouldSpawnFrontend({ checkstack: { type: "frontend" } } as never),
241
+ ).toBe(true);
242
+ });
243
+
244
+ it("returns true for a backend bundle primary that lists a -frontend sibling", () => {
245
+ expect(
246
+ shouldSpawnFrontend({
247
+ checkstack: {
248
+ type: "backend",
249
+ bundle: ["@my-org/widget-common", "@my-org/widget-frontend"],
250
+ },
251
+ } as never),
252
+ ).toBe(true);
253
+ });
254
+
255
+ it("returns false for a backend plugin with no bundle", () => {
256
+ expect(
257
+ shouldSpawnFrontend({ checkstack: { type: "backend" } } as never),
258
+ ).toBe(false);
259
+ });
260
+
261
+ it("returns false for a backend bundle primary with only -common siblings", () => {
262
+ expect(
263
+ shouldSpawnFrontend({
264
+ checkstack: {
265
+ type: "backend",
266
+ bundle: ["@my-org/widget-common"],
267
+ },
268
+ } as never),
269
+ ).toBe(false);
270
+ });
271
+ });
272
+
273
+ // ─────────────────────────────────────────────────────────────────────
274
+ // buildBackendChildEnv
275
+ // ─────────────────────────────────────────────────────────────────────
276
+
277
+ describe("buildBackendChildEnv", () => {
278
+ const args = {
279
+ cwd: "/plugin/cwd",
280
+ port: 4001,
281
+ frontendPort: 5174,
282
+ databaseUrl: "postgresql://x:y@localhost/z",
283
+ watch: true,
284
+ showHelp: false,
285
+ };
286
+
287
+ it("sets the three dev-mode env vars and primary HTTP config", () => {
288
+ const env = buildBackendChildEnv({
289
+ args,
290
+ parentEnv: {},
291
+ extraPluginPaths: ["/a/path", "/b/path"],
292
+ });
293
+ expect(env.CHECKSTACK_DEV_PLUGIN_PATH).toBe("/plugin/cwd");
294
+ expect(env.CHECKSTACK_DEV_AUTH).toBe("true");
295
+ expect(env.CHECKSTACK_DEV_EXTRA_PLUGIN_PATHS).toBe(
296
+ JSON.stringify(["/a/path", "/b/path"]),
297
+ );
298
+ expect(env.PORT).toBe("4001");
299
+ expect(env.DATABASE_URL).toBe("postgresql://x:y@localhost/z");
300
+ expect(env.BASE_URL).toBe("http://localhost:4001");
301
+ expect(env.AUTH_SECRET).toBe("checkstack-dev-secret");
302
+ expect(env.NODE_ENV).toBe("development");
303
+ });
304
+
305
+ it("inherits parent env (e.g. PATH) without dropping it", () => {
306
+ const env = buildBackendChildEnv({
307
+ args,
308
+ parentEnv: { PATH: "/usr/bin:/bin", HOME: "/home/test" },
309
+ extraPluginPaths: [],
310
+ });
311
+ expect(env.PATH).toBe("/usr/bin:/bin");
312
+ expect(env.HOME).toBe("/home/test");
313
+ });
314
+
315
+ it("respects parent BASE_URL / AUTH_SECRET / NODE_ENV when set", () => {
316
+ const env = buildBackendChildEnv({
317
+ args,
318
+ parentEnv: {
319
+ BASE_URL: "http://custom.local",
320
+ AUTH_SECRET: "user-secret",
321
+ NODE_ENV: "test",
322
+ },
323
+ extraPluginPaths: [],
324
+ });
325
+ expect(env.BASE_URL).toBe("http://custom.local");
326
+ expect(env.AUTH_SECRET).toBe("user-secret");
327
+ expect(env.NODE_ENV).toBe("test");
328
+ });
329
+
330
+ it("CHECKSTACK_DEV_EXTRA_PLUGIN_PATHS is valid JSON for an empty list", () => {
331
+ const env = buildBackendChildEnv({
332
+ args,
333
+ parentEnv: {},
334
+ extraPluginPaths: [],
335
+ });
336
+ const parsed = JSON.parse(env.CHECKSTACK_DEV_EXTRA_PLUGIN_PATHS as string);
337
+ expect(parsed).toEqual([]);
338
+ });
339
+ });
340
+
341
+ // ─────────────────────────────────────────────────────────────────────
342
+ // createDebouncedWatcher
343
+ // ─────────────────────────────────────────────────────────────────────
344
+
345
+ describe("createDebouncedWatcher", () => {
346
+ /**
347
+ * Tiny synthetic timer harness. Lets us drive setTimeout callbacks
348
+ * synchronously inside tests without `await new Promise(setTimeout)`
349
+ * games. Each scheduled callback gets a numeric handle; `tick()`
350
+ * fires every callback whose deadline has passed.
351
+ */
352
+ function makeFakeClock() {
353
+ interface ScheduledTimer {
354
+ handle: number;
355
+ fireAt: number;
356
+ fn: () => void;
357
+ }
358
+ const scheduled = new Map<number, ScheduledTimer>();
359
+ let now = 0;
360
+ let nextHandle = 1;
361
+ return {
362
+ setTimer: (fn: () => void, ms: number): TimerHandle => {
363
+ const handle = nextHandle++;
364
+ scheduled.set(handle, { handle, fireAt: now + ms, fn });
365
+ return handle;
366
+ },
367
+ clearTimer: (h: TimerHandle) => {
368
+ scheduled.delete(h as number);
369
+ },
370
+ advance: (ms: number) => {
371
+ now += ms;
372
+ const due = [...scheduled.values()]
373
+ .filter((t) => t.fireAt <= now)
374
+ .toSorted((a, b) => a.fireAt - b.fireAt);
375
+ for (const t of due) {
376
+ scheduled.delete(t.handle);
377
+ t.fn();
378
+ }
379
+ },
380
+ pendingCount: () => scheduled.size,
381
+ };
382
+ }
383
+
384
+ it("calls onTrigger once after the debounce window for a single feed", () => {
385
+ let triggers = 0;
386
+ const clock = makeFakeClock();
387
+ const w = createDebouncedWatcher({
388
+ onTrigger: () => triggers++,
389
+ delayMs: 150,
390
+ setTimer: clock.setTimer,
391
+ clearTimer: clock.clearTimer,
392
+ });
393
+
394
+ w.feed("index.ts");
395
+ expect(triggers).toBe(0);
396
+ expect(clock.pendingCount()).toBe(1);
397
+ clock.advance(150);
398
+ expect(triggers).toBe(1);
399
+ expect(clock.pendingCount()).toBe(0);
400
+ });
401
+
402
+ it("coalesces a burst of feeds into one trigger", () => {
403
+ let triggers = 0;
404
+ const clock = makeFakeClock();
405
+ const w = createDebouncedWatcher({
406
+ onTrigger: () => triggers++,
407
+ delayMs: 150,
408
+ setTimer: clock.setTimer,
409
+ clearTimer: clock.clearTimer,
410
+ });
411
+
412
+ // Burst: 5 events well within the debounce window
413
+ w.feed("a.ts");
414
+ clock.advance(20);
415
+ w.feed("b.ts");
416
+ clock.advance(20);
417
+ w.feed("c.ts");
418
+ clock.advance(20);
419
+ w.feed("d.ts");
420
+ clock.advance(20);
421
+ w.feed("e.ts");
422
+ expect(triggers).toBe(0);
423
+ // Each feed cancelled the previous timer; only the last is pending
424
+ expect(clock.pendingCount()).toBe(1);
425
+
426
+ // Now let the window elapse from the last feed
427
+ clock.advance(150);
428
+ expect(triggers).toBe(1);
429
+ });
430
+
431
+ it("two well-separated feeds trigger twice", () => {
432
+ let triggers = 0;
433
+ const clock = makeFakeClock();
434
+ const w = createDebouncedWatcher({
435
+ onTrigger: () => triggers++,
436
+ delayMs: 150,
437
+ setTimer: clock.setTimer,
438
+ clearTimer: clock.clearTimer,
439
+ });
440
+
441
+ w.feed("a.ts");
442
+ clock.advance(150);
443
+ expect(triggers).toBe(1);
444
+ w.feed("b.ts");
445
+ clock.advance(150);
446
+ expect(triggers).toBe(2);
447
+ });
448
+
449
+ it("ignores undefined filenames", () => {
450
+ let triggers = 0;
451
+ const clock = makeFakeClock();
452
+ const w = createDebouncedWatcher({
453
+ onTrigger: () => triggers++,
454
+ setTimer: clock.setTimer,
455
+ clearTimer: clock.clearTimer,
456
+ });
457
+ w.feed(undefined);
458
+ expect(clock.pendingCount()).toBe(0);
459
+ });
460
+
461
+ it("ignores tilde-suffixed editor swap files", () => {
462
+ let triggers = 0;
463
+ const clock = makeFakeClock();
464
+ const w = createDebouncedWatcher({
465
+ onTrigger: () => triggers++,
466
+ setTimer: clock.setTimer,
467
+ clearTimer: clock.clearTimer,
468
+ });
469
+ w.feed("index.ts~");
470
+ expect(clock.pendingCount()).toBe(0);
471
+ clock.advance(1000);
472
+ expect(triggers).toBe(0);
473
+ });
474
+
475
+ it("ignores dotfiles", () => {
476
+ let triggers = 0;
477
+ const clock = makeFakeClock();
478
+ const w = createDebouncedWatcher({
479
+ onTrigger: () => triggers++,
480
+ setTimer: clock.setTimer,
481
+ clearTimer: clock.clearTimer,
482
+ });
483
+ w.feed(".#index.ts");
484
+ expect(clock.pendingCount()).toBe(0);
485
+ clock.advance(1000);
486
+ expect(triggers).toBe(0);
487
+ });
488
+
489
+ it("dotfile in the middle of a burst does not break the debounce of real edits", () => {
490
+ let triggers = 0;
491
+ const clock = makeFakeClock();
492
+ const w = createDebouncedWatcher({
493
+ onTrigger: () => triggers++,
494
+ delayMs: 150,
495
+ setTimer: clock.setTimer,
496
+ clearTimer: clock.clearTimer,
497
+ });
498
+ w.feed("a.ts");
499
+ clock.advance(50);
500
+ w.feed(".swp");
501
+ clock.advance(50);
502
+ w.feed("b.ts");
503
+ clock.advance(150);
504
+ expect(triggers).toBe(1);
505
+ });
506
+ });