opengstack 0.13.6 → 0.13.8

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 (135) hide show
  1. package/bin/opengstack.js +35 -90
  2. package/package.json +2 -3
  3. package/scripts/install-skills.js +29 -58
  4. package/skills/browse/bin/find-browse +21 -0
  5. package/skills/browse/bin/remote-slug +14 -0
  6. package/skills/browse/scripts/build-node-server.sh +48 -0
  7. package/skills/browse/src/activity.ts +208 -0
  8. package/skills/browse/src/browser-manager.ts +959 -0
  9. package/skills/browse/src/buffers.ts +137 -0
  10. package/skills/browse/src/bun-polyfill.cjs +109 -0
  11. package/skills/browse/src/cli.ts +678 -0
  12. package/skills/browse/src/commands.ts +128 -0
  13. package/skills/browse/src/config.ts +150 -0
  14. package/skills/browse/src/cookie-import-browser.ts +625 -0
  15. package/skills/browse/src/cookie-picker-routes.ts +230 -0
  16. package/skills/browse/src/cookie-picker-ui.ts +688 -0
  17. package/skills/browse/src/find-browse.ts +61 -0
  18. package/skills/browse/src/meta-commands.ts +550 -0
  19. package/skills/browse/src/platform.ts +17 -0
  20. package/skills/browse/src/read-commands.ts +358 -0
  21. package/skills/browse/src/server.ts +1192 -0
  22. package/skills/browse/src/sidebar-agent.ts +280 -0
  23. package/skills/browse/src/sidebar-utils.ts +21 -0
  24. package/skills/browse/src/snapshot.ts +407 -0
  25. package/skills/browse/src/url-validation.ts +95 -0
  26. package/skills/browse/src/write-commands.ts +364 -0
  27. package/skills/browse/test/activity.test.ts +120 -0
  28. package/skills/browse/test/adversarial-security.test.ts +32 -0
  29. package/skills/browse/test/browser-manager-unit.test.ts +17 -0
  30. package/skills/browse/test/bun-polyfill.test.ts +72 -0
  31. package/skills/browse/test/commands.test.ts +2075 -0
  32. package/skills/browse/test/compare-board.test.ts +342 -0
  33. package/skills/browse/test/config.test.ts +316 -0
  34. package/skills/browse/test/cookie-import-browser.test.ts +519 -0
  35. package/skills/browse/test/cookie-picker-routes.test.ts +260 -0
  36. package/skills/browse/test/file-drop.test.ts +271 -0
  37. package/skills/browse/test/find-browse.test.ts +50 -0
  38. package/skills/browse/test/findport.test.ts +191 -0
  39. package/skills/browse/test/fixtures/basic.html +33 -0
  40. package/skills/browse/test/fixtures/cursor-interactive.html +22 -0
  41. package/skills/browse/test/fixtures/dialog.html +15 -0
  42. package/skills/browse/test/fixtures/empty.html +2 -0
  43. package/skills/browse/test/fixtures/forms.html +55 -0
  44. package/skills/browse/test/fixtures/iframe.html +30 -0
  45. package/skills/browse/test/fixtures/network-idle.html +30 -0
  46. package/skills/browse/test/fixtures/qa-eval-checkout.html +108 -0
  47. package/skills/browse/test/fixtures/qa-eval-spa.html +98 -0
  48. package/skills/browse/test/fixtures/qa-eval.html +51 -0
  49. package/skills/browse/test/fixtures/responsive.html +49 -0
  50. package/skills/browse/test/fixtures/snapshot.html +55 -0
  51. package/skills/browse/test/fixtures/spa.html +24 -0
  52. package/skills/browse/test/fixtures/states.html +17 -0
  53. package/skills/browse/test/fixtures/upload.html +25 -0
  54. package/skills/browse/test/gstack-config.test.ts +138 -0
  55. package/skills/browse/test/gstack-update-check.test.ts +514 -0
  56. package/skills/browse/test/handoff.test.ts +235 -0
  57. package/skills/browse/test/path-validation.test.ts +91 -0
  58. package/skills/browse/test/platform.test.ts +37 -0
  59. package/skills/browse/test/server-auth.test.ts +65 -0
  60. package/skills/browse/test/sidebar-agent-roundtrip.test.ts +226 -0
  61. package/skills/browse/test/sidebar-agent.test.ts +199 -0
  62. package/skills/browse/test/sidebar-integration.test.ts +320 -0
  63. package/skills/browse/test/sidebar-unit.test.ts +96 -0
  64. package/skills/browse/test/snapshot.test.ts +467 -0
  65. package/skills/browse/test/state-ttl.test.ts +35 -0
  66. package/skills/browse/test/test-server.ts +57 -0
  67. package/skills/browse/test/url-validation.test.ts +72 -0
  68. package/skills/browse/test/watch.test.ts +129 -0
  69. package/skills/careful/bin/check-careful.sh +112 -0
  70. package/skills/cso/ACKNOWLEDGEMENTS.md +14 -0
  71. package/skills/freeze/bin/check-freeze.sh +79 -0
  72. package/skills/qa/references/issue-taxonomy.md +85 -0
  73. package/skills/qa/templates/qa-report-template.md +126 -0
  74. package/skills/review/TODOS-format.md +62 -0
  75. package/skills/review/checklist.md +220 -0
  76. package/skills/review/design-checklist.md +132 -0
  77. package/skills/review/greptile-triage.md +220 -0
  78. /package/{autoplan → skills/autoplan}/SKILL.md +0 -0
  79. /package/{autoplan → skills/autoplan}/SKILL.md.tmpl +0 -0
  80. /package/{benchmark → skills/benchmark}/SKILL.md +0 -0
  81. /package/{benchmark → skills/benchmark}/SKILL.md.tmpl +0 -0
  82. /package/{browse → skills/browse}/SKILL.md +0 -0
  83. /package/{browse → skills/browse}/SKILL.md.tmpl +0 -0
  84. /package/{canary → skills/canary}/SKILL.md +0 -0
  85. /package/{canary → skills/canary}/SKILL.md.tmpl +0 -0
  86. /package/{careful → skills/careful}/SKILL.md +0 -0
  87. /package/{careful → skills/careful}/SKILL.md.tmpl +0 -0
  88. /package/{codex → skills/codex}/SKILL.md +0 -0
  89. /package/{codex → skills/codex}/SKILL.md.tmpl +0 -0
  90. /package/{connect-chrome → skills/connect-chrome}/SKILL.md +0 -0
  91. /package/{connect-chrome → skills/connect-chrome}/SKILL.md.tmpl +0 -0
  92. /package/{cso → skills/cso}/SKILL.md +0 -0
  93. /package/{cso → skills/cso}/SKILL.md.tmpl +0 -0
  94. /package/{design-consultation → skills/design-consultation}/SKILL.md +0 -0
  95. /package/{design-consultation → skills/design-consultation}/SKILL.md.tmpl +0 -0
  96. /package/{design-review → skills/design-review}/SKILL.md +0 -0
  97. /package/{design-review → skills/design-review}/SKILL.md.tmpl +0 -0
  98. /package/{design-shotgun → skills/design-shotgun}/SKILL.md +0 -0
  99. /package/{design-shotgun → skills/design-shotgun}/SKILL.md.tmpl +0 -0
  100. /package/{document-release → skills/document-release}/SKILL.md +0 -0
  101. /package/{document-release → skills/document-release}/SKILL.md.tmpl +0 -0
  102. /package/{freeze → skills/freeze}/SKILL.md +0 -0
  103. /package/{freeze → skills/freeze}/SKILL.md.tmpl +0 -0
  104. /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md +0 -0
  105. /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md.tmpl +0 -0
  106. /package/{guard → skills/guard}/SKILL.md +0 -0
  107. /package/{guard → skills/guard}/SKILL.md.tmpl +0 -0
  108. /package/{investigate → skills/investigate}/SKILL.md +0 -0
  109. /package/{investigate → skills/investigate}/SKILL.md.tmpl +0 -0
  110. /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md +0 -0
  111. /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md.tmpl +0 -0
  112. /package/{office-hours → skills/office-hours}/SKILL.md +0 -0
  113. /package/{office-hours → skills/office-hours}/SKILL.md.tmpl +0 -0
  114. /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md +0 -0
  115. /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md.tmpl +0 -0
  116. /package/{plan-design-review → skills/plan-design-review}/SKILL.md +0 -0
  117. /package/{plan-design-review → skills/plan-design-review}/SKILL.md.tmpl +0 -0
  118. /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md +0 -0
  119. /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md.tmpl +0 -0
  120. /package/{qa → skills/qa}/SKILL.md +0 -0
  121. /package/{qa → skills/qa}/SKILL.md.tmpl +0 -0
  122. /package/{qa-only → skills/qa-only}/SKILL.md +0 -0
  123. /package/{qa-only → skills/qa-only}/SKILL.md.tmpl +0 -0
  124. /package/{retro → skills/retro}/SKILL.md +0 -0
  125. /package/{retro → skills/retro}/SKILL.md.tmpl +0 -0
  126. /package/{review → skills/review}/SKILL.md +0 -0
  127. /package/{review → skills/review}/SKILL.md.tmpl +0 -0
  128. /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md +0 -0
  129. /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md.tmpl +0 -0
  130. /package/{setup-deploy → skills/setup-deploy}/SKILL.md +0 -0
  131. /package/{setup-deploy → skills/setup-deploy}/SKILL.md.tmpl +0 -0
  132. /package/{ship → skills/ship}/SKILL.md +0 -0
  133. /package/{ship → skills/ship}/SKILL.md.tmpl +0 -0
  134. /package/{unfreeze → skills/unfreeze}/SKILL.md +0 -0
  135. /package/{unfreeze → skills/unfreeze}/SKILL.md.tmpl +0 -0
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Integration test for the design comparison board feedback loop.
3
+ *
4
+ * Tests the DOM polling pattern that plan-design-review, office-hours,
5
+ * and design-consultation use to read user feedback from the comparison board.
6
+ *
7
+ * Flow: generate board HTML → open in browser → verify DOM elements →
8
+ * simulate user interaction → verify structured JSON feedback.
9
+ *
10
+ * No LLM involved — this is a deterministic functional test.
11
+ */
12
+
13
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
14
+ import { BrowserManager } from '../src/browser-manager';
15
+ import { handleReadCommand } from '../src/read-commands';
16
+ import { handleWriteCommand } from '../src/write-commands';
17
+ import { generateCompareHtml } from '../../design/src/compare';
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+
21
+ let bm: BrowserManager;
22
+ let boardUrl: string;
23
+ let server: ReturnType<typeof Bun.serve>;
24
+ let tmpDir: string;
25
+
26
+ // Create a minimal 1x1 pixel PNG for test variants
27
+ function createTestPng(filePath: string): void {
28
+ // Minimal valid PNG: 1x1 red pixel
29
+ const png = Buffer.from(
30
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAI/AL+hc2rNAAAAABJRU5ErkJggg==',
31
+ 'base64'
32
+ );
33
+ fs.writeFileSync(filePath, png);
34
+ }
35
+
36
+ beforeAll(async () => {
37
+ // Create test PNG files
38
+ tmpDir = '/tmp/compare-board-test-' + Date.now();
39
+ fs.mkdirSync(tmpDir, { recursive: true });
40
+
41
+ createTestPng(path.join(tmpDir, 'variant-A.png'));
42
+ createTestPng(path.join(tmpDir, 'variant-B.png'));
43
+ createTestPng(path.join(tmpDir, 'variant-C.png'));
44
+
45
+ // Generate comparison board HTML using the real compare module
46
+ const html = generateCompareHtml([
47
+ path.join(tmpDir, 'variant-A.png'),
48
+ path.join(tmpDir, 'variant-B.png'),
49
+ path.join(tmpDir, 'variant-C.png'),
50
+ ]);
51
+
52
+ // Serve the board via HTTP (browse blocks file:// URLs for security)
53
+ server = Bun.serve({
54
+ port: 0,
55
+ fetch() {
56
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
57
+ },
58
+ });
59
+ boardUrl = `http://localhost:${server.port}`;
60
+
61
+ // Launch browser and navigate to the board
62
+ bm = new BrowserManager();
63
+ await bm.launch();
64
+ await handleWriteCommand('goto', [boardUrl], bm);
65
+ });
66
+
67
+ afterAll(() => {
68
+ try { server.stop(); } catch {}
69
+ fs.rmSync(tmpDir, { recursive: true, force: true });
70
+ setTimeout(() => process.exit(0), 500);
71
+ });
72
+
73
+ // ─── DOM Structure ──────────────────────────────────────────────
74
+
75
+ describe('Comparison board DOM structure', () => {
76
+ test('has hidden status element', async () => {
77
+ const status = await handleReadCommand('js', [
78
+ 'document.getElementById("status").textContent'
79
+ ], bm);
80
+ expect(status).toBe('');
81
+ });
82
+
83
+ test('has hidden feedback-result element', async () => {
84
+ const result = await handleReadCommand('js', [
85
+ 'document.getElementById("feedback-result").textContent'
86
+ ], bm);
87
+ expect(result).toBe('');
88
+ });
89
+
90
+ test('has submit button', async () => {
91
+ const exists = await handleReadCommand('js', [
92
+ '!!document.getElementById("submit-btn")'
93
+ ], bm);
94
+ expect(exists).toBe('true');
95
+ });
96
+
97
+ test('has regenerate button', async () => {
98
+ const exists = await handleReadCommand('js', [
99
+ '!!document.getElementById("regen-btn")'
100
+ ], bm);
101
+ expect(exists).toBe('true');
102
+ });
103
+
104
+ test('has 3 variant cards', async () => {
105
+ const count = await handleReadCommand('js', [
106
+ 'document.querySelectorAll(".variant").length'
107
+ ], bm);
108
+ expect(count).toBe('3');
109
+ });
110
+
111
+ test('has pick radio buttons for each variant', async () => {
112
+ const count = await handleReadCommand('js', [
113
+ 'document.querySelectorAll("input[name=\\"preferred\\"]").length'
114
+ ], bm);
115
+ expect(count).toBe('3');
116
+ });
117
+
118
+ test('has star ratings for each variant', async () => {
119
+ const count = await handleReadCommand('js', [
120
+ 'document.querySelectorAll(".stars").length'
121
+ ], bm);
122
+ expect(count).toBe('3');
123
+ });
124
+ });
125
+
126
+ // ─── Submit Flow ────────────────────────────────────────────────
127
+
128
+ describe('Submit feedback flow', () => {
129
+ test('submit without interaction returns empty preferred', async () => {
130
+ // Reset page state
131
+ await handleWriteCommand('goto', [boardUrl], bm);
132
+
133
+ // Click submit without picking anything
134
+ await handleReadCommand('js', [
135
+ 'document.getElementById("submit-btn").click()'
136
+ ], bm);
137
+
138
+ // Status should be "submitted"
139
+ const status = await handleReadCommand('js', [
140
+ 'document.getElementById("status").textContent'
141
+ ], bm);
142
+ expect(status).toBe('submitted');
143
+
144
+ // Read feedback JSON
145
+ const raw = await handleReadCommand('js', [
146
+ 'document.getElementById("feedback-result").textContent'
147
+ ], bm);
148
+ const feedback = JSON.parse(raw);
149
+ expect(feedback.preferred).toBeNull();
150
+ expect(feedback.regenerated).toBe(false);
151
+ expect(feedback.ratings).toBeDefined();
152
+ });
153
+
154
+ test('submit with pick + rating + comment returns structured JSON', async () => {
155
+ // Fresh page
156
+ await handleWriteCommand('goto', [boardUrl], bm);
157
+
158
+ // Pick variant B
159
+ await handleReadCommand('js', [
160
+ 'document.querySelectorAll("input[name=\\"preferred\\"]")[1].click()'
161
+ ], bm);
162
+
163
+ // Rate variant A: 4 stars (click the 4th star)
164
+ await handleReadCommand('js', [
165
+ 'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[3].click()'
166
+ ], bm);
167
+
168
+ // Rate variant B: 5 stars
169
+ await handleReadCommand('js', [
170
+ 'document.querySelectorAll(".stars")[1].querySelectorAll(".star")[4].click()'
171
+ ], bm);
172
+
173
+ // Add comment on variant A
174
+ await handleReadCommand('js', [
175
+ 'document.querySelectorAll(".feedback-input")[0].value = "Good spacing but wrong colors"'
176
+ ], bm);
177
+
178
+ // Add overall feedback
179
+ await handleReadCommand('js', [
180
+ 'document.getElementById("overall-feedback").value = "Go with B, make the CTA bigger"'
181
+ ], bm);
182
+
183
+ // Submit
184
+ await handleReadCommand('js', [
185
+ 'document.getElementById("submit-btn").click()'
186
+ ], bm);
187
+
188
+ // Verify status
189
+ const status = await handleReadCommand('js', [
190
+ 'document.getElementById("status").textContent'
191
+ ], bm);
192
+ expect(status).toBe('submitted');
193
+
194
+ // Read and verify structured feedback
195
+ const raw = await handleReadCommand('js', [
196
+ 'document.getElementById("feedback-result").textContent'
197
+ ], bm);
198
+ const feedback = JSON.parse(raw);
199
+
200
+ expect(feedback.preferred).toBe('B');
201
+ expect(feedback.ratings.A).toBe(4);
202
+ expect(feedback.ratings.B).toBe(5);
203
+ expect(feedback.comments.A).toBe('Good spacing but wrong colors');
204
+ expect(feedback.overall).toBe('Go with B, make the CTA bigger');
205
+ expect(feedback.regenerated).toBe(false);
206
+ });
207
+
208
+ test('submit button is disabled after submission', async () => {
209
+ const disabled = await handleReadCommand('js', [
210
+ 'document.getElementById("submit-btn").disabled'
211
+ ], bm);
212
+ expect(disabled).toBe('true');
213
+ });
214
+
215
+ test('success message is visible after submission', async () => {
216
+ const display = await handleReadCommand('js', [
217
+ 'document.getElementById("success-msg").style.display'
218
+ ], bm);
219
+ expect(display).toBe('block');
220
+ });
221
+ });
222
+
223
+ // ─── Regenerate Flow ────────────────────────────────────────────
224
+
225
+ describe('Regenerate flow', () => {
226
+ test('regenerate button sets status to "regenerate"', async () => {
227
+ // Fresh page
228
+ await handleWriteCommand('goto', [boardUrl], bm);
229
+
230
+ // Click "Totally different" chiclet then regenerate
231
+ await handleReadCommand('js', [
232
+ 'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
233
+ ], bm);
234
+ await handleReadCommand('js', [
235
+ 'document.getElementById("regen-btn").click()'
236
+ ], bm);
237
+
238
+ const status = await handleReadCommand('js', [
239
+ 'document.getElementById("status").textContent'
240
+ ], bm);
241
+ expect(status).toBe('regenerate');
242
+
243
+ // Verify regenerate action in feedback
244
+ const raw = await handleReadCommand('js', [
245
+ 'document.getElementById("feedback-result").textContent'
246
+ ], bm);
247
+ const feedback = JSON.parse(raw);
248
+ expect(feedback.regenerated).toBe(true);
249
+ expect(feedback.regenerateAction).toBe('different');
250
+ });
251
+
252
+ test('"More like this" sets regenerate with variant reference', async () => {
253
+ // Fresh page
254
+ await handleWriteCommand('goto', [boardUrl], bm);
255
+
256
+ // Click "More like this" on variant B
257
+ await handleReadCommand('js', [
258
+ 'document.querySelectorAll(".more-like-this")[1].click()'
259
+ ], bm);
260
+
261
+ const status = await handleReadCommand('js', [
262
+ 'document.getElementById("status").textContent'
263
+ ], bm);
264
+ expect(status).toBe('regenerate');
265
+
266
+ const raw = await handleReadCommand('js', [
267
+ 'document.getElementById("feedback-result").textContent'
268
+ ], bm);
269
+ const feedback = JSON.parse(raw);
270
+ expect(feedback.regenerated).toBe(true);
271
+ expect(feedback.regenerateAction).toBe('more_like_B');
272
+ });
273
+
274
+ test('regenerate with custom text', async () => {
275
+ // Fresh page
276
+ await handleWriteCommand('goto', [boardUrl], bm);
277
+
278
+ // Type custom regeneration text
279
+ await handleReadCommand('js', [
280
+ 'document.getElementById("regen-custom-input").value = "V3 layout with V1 colors"'
281
+ ], bm);
282
+
283
+ // Click regenerate (no chiclet selected = custom)
284
+ await handleReadCommand('js', [
285
+ 'document.getElementById("regen-btn").click()'
286
+ ], bm);
287
+
288
+ const raw = await handleReadCommand('js', [
289
+ 'document.getElementById("feedback-result").textContent'
290
+ ], bm);
291
+ const feedback = JSON.parse(raw);
292
+ expect(feedback.regenerated).toBe(true);
293
+ expect(feedback.regenerateAction).toBe('V3 layout with V1 colors');
294
+ });
295
+ });
296
+
297
+ // ─── Agent Polling Pattern ──────────────────────────────────────
298
+
299
+ describe('Agent polling pattern (simulates what $B eval does)', () => {
300
+ test('status is empty before user action', async () => {
301
+ // Fresh page — simulates agent's first poll
302
+ await handleWriteCommand('goto', [boardUrl], bm);
303
+
304
+ const status = await handleReadCommand('js', [
305
+ 'document.getElementById("status").textContent'
306
+ ], bm);
307
+ expect(status).toBe('');
308
+ });
309
+
310
+ test('full polling cycle: empty → submitted → read JSON', async () => {
311
+ await handleWriteCommand('goto', [boardUrl], bm);
312
+
313
+ // Poll 1: empty (user hasn't acted)
314
+ const poll1 = await handleReadCommand('js', [
315
+ 'document.getElementById("status").textContent'
316
+ ], bm);
317
+ expect(poll1).toBe('');
318
+
319
+ // User acts: pick A, submit
320
+ await handleReadCommand('js', [
321
+ 'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
322
+ ], bm);
323
+ await handleReadCommand('js', [
324
+ 'document.getElementById("submit-btn").click()'
325
+ ], bm);
326
+
327
+ // Poll 2: submitted
328
+ const poll2 = await handleReadCommand('js', [
329
+ 'document.getElementById("status").textContent'
330
+ ], bm);
331
+ expect(poll2).toBe('submitted');
332
+
333
+ // Read feedback (what the agent does after seeing "submitted")
334
+ const raw = await handleReadCommand('js', [
335
+ 'document.getElementById("feedback-result").textContent'
336
+ ], bm);
337
+ const feedback = JSON.parse(raw);
338
+ expect(feedback.preferred).toBe('A');
339
+ expect(typeof feedback.ratings).toBe('object');
340
+ expect(typeof feedback.comments).toBe('object');
341
+ });
342
+ });
@@ -0,0 +1,316 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug } from '../src/config';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+
7
+ describe('config', () => {
8
+ describe('getGitRoot', () => {
9
+ test('returns a path when in a git repo', () => {
10
+ const root = getGitRoot();
11
+ expect(root).not.toBeNull();
12
+ expect(fs.existsSync(path.join(root!, '.git'))).toBe(true);
13
+ });
14
+ });
15
+
16
+ describe('resolveConfig', () => {
17
+ test('uses git root by default', () => {
18
+ const config = resolveConfig({});
19
+ const gitRoot = getGitRoot();
20
+ expect(gitRoot).not.toBeNull();
21
+ expect(config.projectDir).toBe(gitRoot);
22
+ expect(config.stateDir).toBe(path.join(gitRoot!, '.gstack'));
23
+ expect(config.stateFile).toBe(path.join(gitRoot!, '.gstack', 'browse.json'));
24
+ });
25
+
26
+ test('derives paths from BROWSE_STATE_FILE when set', () => {
27
+ const stateFile = '/tmp/test-config/.gstack/browse.json';
28
+ const config = resolveConfig({ BROWSE_STATE_FILE: stateFile });
29
+ expect(config.stateFile).toBe(stateFile);
30
+ expect(config.stateDir).toBe('/tmp/test-config/.gstack');
31
+ expect(config.projectDir).toBe('/tmp/test-config');
32
+ });
33
+
34
+ test('log paths are in stateDir', () => {
35
+ const config = resolveConfig({});
36
+ expect(config.consoleLog).toBe(path.join(config.stateDir, 'browse-console.log'));
37
+ expect(config.networkLog).toBe(path.join(config.stateDir, 'browse-network.log'));
38
+ expect(config.dialogLog).toBe(path.join(config.stateDir, 'browse-dialog.log'));
39
+ });
40
+ });
41
+
42
+ describe('ensureStateDir', () => {
43
+ test('creates directory if it does not exist', () => {
44
+ const tmpDir = path.join(os.tmpdir(), `browse-config-test-${Date.now()}`);
45
+ const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
46
+ expect(fs.existsSync(config.stateDir)).toBe(false);
47
+ ensureStateDir(config);
48
+ expect(fs.existsSync(config.stateDir)).toBe(true);
49
+ // Cleanup
50
+ fs.rmSync(tmpDir, { recursive: true, force: true });
51
+ });
52
+
53
+ test('is a no-op if directory already exists', () => {
54
+ const tmpDir = path.join(os.tmpdir(), `browse-config-test-${Date.now()}`);
55
+ const stateDir = path.join(tmpDir, '.gstack');
56
+ fs.mkdirSync(stateDir, { recursive: true });
57
+ const config = resolveConfig({ BROWSE_STATE_FILE: path.join(stateDir, 'browse.json') });
58
+ ensureStateDir(config); // should not throw
59
+ expect(fs.existsSync(config.stateDir)).toBe(true);
60
+ // Cleanup
61
+ fs.rmSync(tmpDir, { recursive: true, force: true });
62
+ });
63
+
64
+ test('adds .gstack/ to .gitignore if not present', () => {
65
+ const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
66
+ fs.mkdirSync(tmpDir, { recursive: true });
67
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
68
+ const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
69
+ ensureStateDir(config);
70
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
71
+ expect(content).toContain('.gstack/');
72
+ expect(content).toBe('node_modules/\n.gstack/\n');
73
+ fs.rmSync(tmpDir, { recursive: true, force: true });
74
+ });
75
+
76
+ test('does not duplicate .gstack/ in .gitignore', () => {
77
+ const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
78
+ fs.mkdirSync(tmpDir, { recursive: true });
79
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/\n');
80
+ const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
81
+ ensureStateDir(config);
82
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
83
+ expect(content).toBe('node_modules/\n.gstack/\n');
84
+ fs.rmSync(tmpDir, { recursive: true, force: true });
85
+ });
86
+
87
+ test('handles .gitignore without trailing newline', () => {
88
+ const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
89
+ fs.mkdirSync(tmpDir, { recursive: true });
90
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules');
91
+ const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
92
+ ensureStateDir(config);
93
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
94
+ expect(content).toBe('node_modules\n.gstack/\n');
95
+ fs.rmSync(tmpDir, { recursive: true, force: true });
96
+ });
97
+
98
+ test('logs warning to browse-server.log on non-ENOENT gitignore error', () => {
99
+ const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
100
+ fs.mkdirSync(tmpDir, { recursive: true });
101
+ // Create a read-only .gitignore (no .gstack/ entry → would try to append)
102
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
103
+ fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o444);
104
+ const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
105
+ ensureStateDir(config); // should not throw
106
+ // Verify warning was written to server log
107
+ const logPath = path.join(config.stateDir, 'browse-server.log');
108
+ expect(fs.existsSync(logPath)).toBe(true);
109
+ const logContent = fs.readFileSync(logPath, 'utf-8');
110
+ expect(logContent).toContain('Warning: could not update .gitignore');
111
+ // .gitignore should remain unchanged
112
+ const gitignoreContent = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
113
+ expect(gitignoreContent).toBe('node_modules/\n');
114
+ // Cleanup
115
+ fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o644);
116
+ fs.rmSync(tmpDir, { recursive: true, force: true });
117
+ });
118
+
119
+ test('skips if no .gitignore exists', () => {
120
+ const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
121
+ fs.mkdirSync(tmpDir, { recursive: true });
122
+ const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
123
+ ensureStateDir(config);
124
+ expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false);
125
+ fs.rmSync(tmpDir, { recursive: true, force: true });
126
+ });
127
+ });
128
+
129
+ describe('getRemoteSlug', () => {
130
+ test('returns owner-repo format for current repo', () => {
131
+ const slug = getRemoteSlug();
132
+ // This repo has an origin remote — should return a slug
133
+ expect(slug).toBeTruthy();
134
+ expect(slug).toMatch(/^[a-zA-Z0-9._-]+-[a-zA-Z0-9._-]+$/);
135
+ });
136
+
137
+ test('parses SSH remote URLs', () => {
138
+ // Test the regex directly since we can't mock Bun.spawnSync easily
139
+ const url = 'git@github.com:garrytan/gstack.git';
140
+ const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
141
+ expect(match).not.toBeNull();
142
+ expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
143
+ });
144
+
145
+ test('parses HTTPS remote URLs', () => {
146
+ const url = 'https://github.com/garrytan/gstack.git';
147
+ const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
148
+ expect(match).not.toBeNull();
149
+ expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
150
+ });
151
+
152
+ test('parses HTTPS remote URLs without .git suffix', () => {
153
+ const url = 'https://github.com/garrytan/gstack';
154
+ const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
155
+ expect(match).not.toBeNull();
156
+ expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
157
+ });
158
+ });
159
+
160
+ describe('readVersionHash', () => {
161
+ test('returns null when .version file does not exist', () => {
162
+ const result = readVersionHash('/nonexistent/path/browse');
163
+ expect(result).toBeNull();
164
+ });
165
+
166
+ test('reads version from .version file adjacent to execPath', () => {
167
+ const tmpDir = path.join(os.tmpdir(), `browse-version-test-${Date.now()}`);
168
+ fs.mkdirSync(tmpDir, { recursive: true });
169
+ const versionFile = path.join(tmpDir, '.version');
170
+ fs.writeFileSync(versionFile, 'abc123def\n');
171
+ const result = readVersionHash(path.join(tmpDir, 'browse'));
172
+ expect(result).toBe('abc123def');
173
+ // Cleanup
174
+ fs.rmSync(tmpDir, { recursive: true, force: true });
175
+ });
176
+ });
177
+ });
178
+
179
+ describe('resolveServerScript', () => {
180
+ // Import the function from cli.ts
181
+ const { resolveServerScript } = require('../src/cli');
182
+
183
+ test('uses BROWSE_SERVER_SCRIPT env when set', () => {
184
+ const result = resolveServerScript({ BROWSE_SERVER_SCRIPT: '/custom/server.ts' }, '', '');
185
+ expect(result).toBe('/custom/server.ts');
186
+ });
187
+
188
+ test('finds server.ts adjacent to cli.ts in dev mode', () => {
189
+ const srcDir = path.resolve(__dirname, '../src');
190
+ const result = resolveServerScript({}, srcDir, '');
191
+ expect(result).toBe(path.join(srcDir, 'server.ts'));
192
+ });
193
+
194
+ test('throws when server.ts cannot be found', () => {
195
+ expect(() => resolveServerScript({}, '/nonexistent/$bunfs', '/nonexistent/browse'))
196
+ .toThrow('Cannot find server.ts');
197
+ });
198
+ });
199
+
200
+ describe('resolveNodeServerScript', () => {
201
+ const { resolveNodeServerScript } = require('../src/cli');
202
+
203
+ test('finds server-node.mjs in dist from dev mode', () => {
204
+ const srcDir = path.resolve(__dirname, '../src');
205
+ const distFile = path.resolve(srcDir, '..', 'dist', 'server-node.mjs');
206
+ const fs = require('fs');
207
+ // Only test if the file exists (it may not be built yet)
208
+ if (fs.existsSync(distFile)) {
209
+ const result = resolveNodeServerScript(srcDir, '');
210
+ expect(result).toBe(distFile);
211
+ }
212
+ });
213
+
214
+ test('returns null when server-node.mjs does not exist', () => {
215
+ const result = resolveNodeServerScript('/nonexistent/$bunfs', '/nonexistent/browse');
216
+ expect(result).toBeNull();
217
+ });
218
+
219
+ test('finds server-node.mjs adjacent to compiled binary', () => {
220
+ const distDir = path.resolve(__dirname, '../dist');
221
+ const distFile = path.join(distDir, 'server-node.mjs');
222
+ const fs = require('fs');
223
+ if (fs.existsSync(distFile)) {
224
+ const result = resolveNodeServerScript('/$bunfs/something', path.join(distDir, 'browse'));
225
+ expect(result).toBe(distFile);
226
+ }
227
+ });
228
+ });
229
+
230
+ describe('version mismatch detection', () => {
231
+ test('detects when versions differ', () => {
232
+ const stateVersion = 'abc123';
233
+ const currentVersion = 'def456';
234
+ expect(stateVersion !== currentVersion).toBe(true);
235
+ });
236
+
237
+ test('no mismatch when versions match', () => {
238
+ const stateVersion = 'abc123';
239
+ const currentVersion = 'abc123';
240
+ expect(stateVersion !== currentVersion).toBe(false);
241
+ });
242
+
243
+ test('no mismatch when either version is null', () => {
244
+ const currentVersion: string | null = null;
245
+ const stateVersion: string | undefined = 'abc123';
246
+ // Version mismatch only triggers when both are present
247
+ const shouldRestart = currentVersion !== null && stateVersion !== undefined && currentVersion !== stateVersion;
248
+ expect(shouldRestart).toBe(false);
249
+ });
250
+ });
251
+
252
+ describe('isServerHealthy', () => {
253
+ const { isServerHealthy } = require('../src/cli');
254
+ const http = require('http');
255
+
256
+ test('returns true for a healthy server', async () => {
257
+ const server = http.createServer((_req: any, res: any) => {
258
+ res.writeHead(200, { 'Content-Type': 'application/json' });
259
+ res.end(JSON.stringify({ status: 'healthy' }));
260
+ });
261
+ await new Promise<void>(resolve => server.listen(0, resolve));
262
+ const port = server.address().port;
263
+ try {
264
+ expect(await isServerHealthy(port)).toBe(true);
265
+ } finally {
266
+ server.close();
267
+ }
268
+ });
269
+
270
+ test('returns false for an unhealthy server', async () => {
271
+ const server = http.createServer((_req: any, res: any) => {
272
+ res.writeHead(200, { 'Content-Type': 'application/json' });
273
+ res.end(JSON.stringify({ status: 'unhealthy' }));
274
+ });
275
+ await new Promise<void>(resolve => server.listen(0, resolve));
276
+ const port = server.address().port;
277
+ try {
278
+ expect(await isServerHealthy(port)).toBe(false);
279
+ } finally {
280
+ server.close();
281
+ }
282
+ });
283
+
284
+ test('returns false when server is not running', async () => {
285
+ // Use a port that's almost certainly not in use
286
+ expect(await isServerHealthy(59999)).toBe(false);
287
+ });
288
+
289
+ test('returns false on non-200 response', async () => {
290
+ const server = http.createServer((_req: any, res: any) => {
291
+ res.writeHead(500);
292
+ res.end('Internal Server Error');
293
+ });
294
+ await new Promise<void>(resolve => server.listen(0, resolve));
295
+ const port = server.address().port;
296
+ try {
297
+ expect(await isServerHealthy(port)).toBe(false);
298
+ } finally {
299
+ server.close();
300
+ }
301
+ });
302
+ });
303
+
304
+ describe('startup error log', () => {
305
+ test('write and read error log', () => {
306
+ const tmpDir = path.join(os.tmpdir(), `browse-error-log-test-${Date.now()}`);
307
+ fs.mkdirSync(tmpDir, { recursive: true });
308
+ const errorLogPath = path.join(tmpDir, 'browse-startup-error.log');
309
+ const errorMsg = 'Cannot find module playwright';
310
+ fs.writeFileSync(errorLogPath, `2026-03-23T00:00:00.000Z ${errorMsg}\n`);
311
+ const content = fs.readFileSync(errorLogPath, 'utf-8').trim();
312
+ expect(content).toContain(errorMsg);
313
+ expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO timestamp prefix
314
+ fs.rmSync(tmpDir, { recursive: true, force: true });
315
+ });
316
+ });