@wangyaoshen/remux 0.3.8-dev.a8ceb0c

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 (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Tests for Web Push module (src/push.ts) and store push subscription CRUD.
3
+ * VAPID key generation/persistence, subscription management, broadcast logic.
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
7
+ import Database from "better-sqlite3";
8
+ import {
9
+ _resetDbForTest,
10
+ closeDb,
11
+ getSetting,
12
+ setSetting,
13
+ savePushSubscription,
14
+ getPushSubscription,
15
+ removePushSubscription,
16
+ listPushSubscriptions,
17
+ createDevice,
18
+ } from "../src/store.ts";
19
+
20
+ /** Create an in-memory SQLite DB with the same schema as store.ts. */
21
+ function createTestDb() {
22
+ const db = new Database(":memory:");
23
+ db.pragma("journal_mode = WAL");
24
+ db.pragma("foreign_keys = ON");
25
+ db.exec(`
26
+ CREATE TABLE IF NOT EXISTS sessions (
27
+ name TEXT PRIMARY KEY,
28
+ created_at INTEGER NOT NULL
29
+ );
30
+ CREATE TABLE IF NOT EXISTS tabs (
31
+ id INTEGER PRIMARY KEY,
32
+ session_name TEXT NOT NULL,
33
+ title TEXT NOT NULL DEFAULT 'Tab',
34
+ scrollback BLOB,
35
+ ended INTEGER NOT NULL DEFAULT 0,
36
+ FOREIGN KEY (session_name) REFERENCES sessions(name) ON DELETE CASCADE
37
+ );
38
+ CREATE TABLE IF NOT EXISTS devices (
39
+ id TEXT PRIMARY KEY,
40
+ name TEXT NOT NULL,
41
+ fingerprint TEXT NOT NULL,
42
+ trust TEXT NOT NULL DEFAULT 'untrusted',
43
+ created_at INTEGER NOT NULL,
44
+ last_seen INTEGER NOT NULL
45
+ );
46
+ CREATE TABLE IF NOT EXISTS pair_codes (
47
+ code TEXT PRIMARY KEY,
48
+ created_by TEXT NOT NULL,
49
+ expires_at INTEGER NOT NULL,
50
+ FOREIGN KEY (created_by) REFERENCES devices(id) ON DELETE CASCADE
51
+ );
52
+ CREATE TABLE IF NOT EXISTS settings (
53
+ key TEXT PRIMARY KEY,
54
+ value TEXT
55
+ );
56
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
57
+ device_id TEXT PRIMARY KEY,
58
+ endpoint TEXT NOT NULL,
59
+ p256dh TEXT NOT NULL,
60
+ auth TEXT NOT NULL,
61
+ created_at INTEGER NOT NULL
62
+ );
63
+ `);
64
+ return db;
65
+ }
66
+
67
+ describe("store: settings KV", () => {
68
+ let db;
69
+
70
+ beforeEach(() => {
71
+ db = createTestDb();
72
+ _resetDbForTest(db);
73
+ });
74
+
75
+ afterEach(() => {
76
+ closeDb();
77
+ });
78
+
79
+ it("getSetting returns null for missing key", () => {
80
+ expect(getSetting("nonexistent")).toBeNull();
81
+ });
82
+
83
+ it("setSetting creates a new setting", () => {
84
+ setSetting("test_key", "test_value");
85
+ expect(getSetting("test_key")).toBe("test_value");
86
+ });
87
+
88
+ it("setSetting upserts existing setting", () => {
89
+ setSetting("key", "old_value");
90
+ setSetting("key", "new_value");
91
+ expect(getSetting("key")).toBe("new_value");
92
+ });
93
+
94
+ it("handles empty string values", () => {
95
+ setSetting("empty", "");
96
+ expect(getSetting("empty")).toBe("");
97
+ });
98
+ });
99
+
100
+ describe("store: push subscriptions", () => {
101
+ let db;
102
+
103
+ beforeEach(() => {
104
+ db = createTestDb();
105
+ _resetDbForTest(db);
106
+ });
107
+
108
+ afterEach(() => {
109
+ closeDb();
110
+ });
111
+
112
+ it("saves and retrieves a push subscription", () => {
113
+ savePushSubscription("dev-1", "https://push.example.com/sub1", "p256dh_key", "auth_key");
114
+ const sub = getPushSubscription("dev-1");
115
+ expect(sub).not.toBeNull();
116
+ expect(sub.deviceId).toBe("dev-1");
117
+ expect(sub.endpoint).toBe("https://push.example.com/sub1");
118
+ expect(sub.p256dh).toBe("p256dh_key");
119
+ expect(sub.auth).toBe("auth_key");
120
+ expect(sub.createdAt).toBeGreaterThan(0);
121
+ });
122
+
123
+ it("returns null for non-existent subscription", () => {
124
+ expect(getPushSubscription("nonexistent")).toBeNull();
125
+ });
126
+
127
+ it("upserts subscription for same device", () => {
128
+ savePushSubscription("dev-1", "https://old.example.com", "old_p256dh", "old_auth");
129
+ savePushSubscription("dev-1", "https://new.example.com", "new_p256dh", "new_auth");
130
+
131
+ const sub = getPushSubscription("dev-1");
132
+ expect(sub.endpoint).toBe("https://new.example.com");
133
+ expect(sub.p256dh).toBe("new_p256dh");
134
+ expect(sub.auth).toBe("new_auth");
135
+
136
+ // Should only be one record
137
+ const all = listPushSubscriptions();
138
+ expect(all).toHaveLength(1);
139
+ });
140
+
141
+ it("removes a push subscription", () => {
142
+ savePushSubscription("dev-1", "https://push.example.com", "p256dh", "auth");
143
+ expect(removePushSubscription("dev-1")).toBe(true);
144
+ expect(getPushSubscription("dev-1")).toBeNull();
145
+ });
146
+
147
+ it("removePushSubscription returns false for non-existent", () => {
148
+ expect(removePushSubscription("nonexistent")).toBe(false);
149
+ });
150
+
151
+ it("lists all push subscriptions", () => {
152
+ savePushSubscription("dev-1", "https://push.example.com/1", "p1", "a1");
153
+ savePushSubscription("dev-2", "https://push.example.com/2", "p2", "a2");
154
+ savePushSubscription("dev-3", "https://push.example.com/3", "p3", "a3");
155
+
156
+ const all = listPushSubscriptions();
157
+ expect(all).toHaveLength(3);
158
+ const deviceIds = all.map((s) => s.deviceId);
159
+ expect(deviceIds).toContain("dev-1");
160
+ expect(deviceIds).toContain("dev-2");
161
+ expect(deviceIds).toContain("dev-3");
162
+ });
163
+
164
+ it("empty list when no subscriptions", () => {
165
+ expect(listPushSubscriptions()).toHaveLength(0);
166
+ });
167
+ });
168
+
169
+ describe("push: VAPID key generation and persistence", () => {
170
+ let db;
171
+
172
+ beforeEach(() => {
173
+ db = createTestDb();
174
+ _resetDbForTest(db);
175
+ });
176
+
177
+ afterEach(() => {
178
+ closeDb();
179
+ vi.restoreAllMocks();
180
+ });
181
+
182
+ it("initPush generates and persists VAPID keys on first run", async () => {
183
+ // Dynamic import to get fresh module state
184
+ const { initPush, getVapidPublicKey, isPushReady } = await import("../src/push.ts");
185
+
186
+ // Before init
187
+ // Note: module state persists across tests, so we just verify initPush works
188
+ initPush();
189
+
190
+ expect(isPushReady()).toBe(true);
191
+ const publicKey = getVapidPublicKey();
192
+ expect(publicKey).not.toBeNull();
193
+ expect(typeof publicKey).toBe("string");
194
+ expect(publicKey.length).toBeGreaterThan(0);
195
+
196
+ // Verify keys were persisted in settings
197
+ const storedPub = getSetting("vapid_public_key");
198
+ const storedPriv = getSetting("vapid_private_key");
199
+ expect(storedPub).toBe(publicKey);
200
+ expect(storedPriv).not.toBeNull();
201
+ expect(storedPriv.length).toBeGreaterThan(0);
202
+ });
203
+
204
+ it("initPush loads existing VAPID keys on subsequent run", async () => {
205
+ // Pre-populate settings with known keys
206
+ const { initPush: initPush2, getVapidPublicKey: getKey2 } = await import("../src/push.ts");
207
+
208
+ // First init generates keys
209
+ initPush2();
210
+ const firstKey = getKey2();
211
+
212
+ // Store the key, re-init should load the same key
213
+ const storedKey = getSetting("vapid_public_key");
214
+ expect(storedKey).toBe(firstKey);
215
+
216
+ // Re-init should use stored keys (not generate new ones)
217
+ initPush2();
218
+ expect(getKey2()).toBe(firstKey);
219
+ });
220
+ });
221
+
222
+ describe("push: sendPushNotification", () => {
223
+ let db;
224
+
225
+ beforeEach(() => {
226
+ db = createTestDb();
227
+ _resetDbForTest(db);
228
+ });
229
+
230
+ afterEach(() => {
231
+ closeDb();
232
+ vi.restoreAllMocks();
233
+ });
234
+
235
+ it("returns false when no subscription exists for device", async () => {
236
+ const { initPush, sendPushNotification } = await import("../src/push.ts");
237
+ initPush();
238
+
239
+ const result = await sendPushNotification("no-such-device", "Title", "Body");
240
+ expect(result).toBe(false);
241
+ });
242
+ });
243
+
244
+ describe("push: broadcastPush", () => {
245
+ let db;
246
+
247
+ beforeEach(() => {
248
+ db = createTestDb();
249
+ _resetDbForTest(db);
250
+ });
251
+
252
+ afterEach(() => {
253
+ closeDb();
254
+ vi.restoreAllMocks();
255
+ });
256
+
257
+ it("skips excluded device IDs", async () => {
258
+ const { initPush, broadcastPush } = await import("../src/push.ts");
259
+ initPush();
260
+
261
+ // Add subscriptions for two devices
262
+ savePushSubscription("dev-a", "https://push.example.com/a", "pa", "aa");
263
+ savePushSubscription("dev-b", "https://push.example.com/b", "pb", "ab");
264
+
265
+ // broadcastPush with both excluded should effectively be a no-op
266
+ // (won't throw since it just filters them out)
267
+ await broadcastPush("Test", "Body", ["dev-a", "dev-b"]);
268
+
269
+ // Verify subscriptions still exist (not removed)
270
+ expect(getPushSubscription("dev-a")).not.toBeNull();
271
+ expect(getPushSubscription("dev-b")).not.toBeNull();
272
+ });
273
+
274
+ it("handles empty subscription list gracefully", async () => {
275
+ const { initPush, broadcastPush } = await import("../src/push.ts");
276
+ initPush();
277
+
278
+ // Should not throw
279
+ await broadcastPush("Test", "Body");
280
+ });
281
+ });
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Tests for workspace content renderers: diff, markdown, ANSI.
3
+ * Covers detectContentType, renderDiff, renderMarkdown, renderAnsi.
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ detectContentType,
9
+ renderDiff,
10
+ renderMarkdown,
11
+ renderAnsi,
12
+ } from "../src/renderers.ts";
13
+
14
+ // ── detectContentType ──────────────────────────────────────────
15
+
16
+ describe("detectContentType", () => {
17
+ it("identifies unified diff (diff --git header)", () => {
18
+ const text = `diff --git a/foo.js b/foo.js
19
+ index 1234567..abcdefg 100644
20
+ --- a/foo.js
21
+ +++ b/foo.js
22
+ @@ -1,3 +1,4 @@
23
+ const a = 1;
24
+ +const b = 2;
25
+ const c = 3;`;
26
+ expect(detectContentType(text)).toBe("diff");
27
+ });
28
+
29
+ it("identifies unified diff (--- and +++ headers)", () => {
30
+ const text = `--- a/file.txt
31
+ +++ b/file.txt
32
+ @@ -1,2 +1,3 @@
33
+ hello
34
+ +world
35
+ end`;
36
+ expect(detectContentType(text)).toBe("diff");
37
+ });
38
+
39
+ it("identifies markdown with headers", () => {
40
+ const text = `# Hello World
41
+
42
+ This is a paragraph with **bold** text.
43
+
44
+ ## Section Two
45
+
46
+ - item one
47
+ - item two`;
48
+ expect(detectContentType(text)).toBe("markdown");
49
+ });
50
+
51
+ it("identifies markdown with code blocks", () => {
52
+ const text = "Some text\n\n```js\nconst x = 1;\n```\n\nMore text";
53
+ expect(detectContentType(text)).toBe("markdown");
54
+ });
55
+
56
+ it("identifies markdown with links", () => {
57
+ const text = "Check out [this link](https://example.com) for more info.";
58
+ expect(detectContentType(text)).toBe("markdown");
59
+ });
60
+
61
+ it("identifies ANSI escape sequences", () => {
62
+ const text = "\x1b[31mERROR:\x1b[0m Something went wrong";
63
+ expect(detectContentType(text)).toBe("ansi");
64
+ });
65
+
66
+ it("returns plain for unformatted text", () => {
67
+ const text = "Just a regular string with no special formatting.";
68
+ expect(detectContentType(text)).toBe("plain");
69
+ });
70
+
71
+ it("returns plain for empty string", () => {
72
+ expect(detectContentType("")).toBe("plain");
73
+ });
74
+
75
+ it("diff takes priority over markdown when both present", () => {
76
+ const text = `diff --git a/README.md b/README.md
77
+ --- a/README.md
78
+ +++ b/README.md
79
+ @@ -1 +1,2 @@
80
+ # Title
81
+ +**bold addition**`;
82
+ expect(detectContentType(text)).toBe("diff");
83
+ });
84
+
85
+ it("ANSI takes priority over plain text", () => {
86
+ const text = "normal text \x1b[1mbold text\x1b[0m more normal";
87
+ expect(detectContentType(text)).toBe("ansi");
88
+ });
89
+ });
90
+
91
+ // ── renderDiff ─────────────────────────────────────────────────
92
+
93
+ describe("renderDiff", () => {
94
+ it("renders additions with diff-add class", () => {
95
+ const diff = `--- a/f.txt
96
+ +++ b/f.txt
97
+ @@ -1 +1,2 @@
98
+ existing
99
+ +added line`;
100
+ const html = renderDiff(diff);
101
+ expect(html).toContain('class="diff-add"');
102
+ expect(html).toContain("added line");
103
+ });
104
+
105
+ it("renders deletions with diff-del class", () => {
106
+ const diff = `--- a/f.txt
107
+ +++ b/f.txt
108
+ @@ -1,2 +1 @@
109
+ -removed line
110
+ kept`;
111
+ const html = renderDiff(diff);
112
+ expect(html).toContain('class="diff-del"');
113
+ expect(html).toContain("removed line");
114
+ });
115
+
116
+ it("renders hunk headers with diff-hunk class", () => {
117
+ const diff = `--- a/f.txt
118
+ +++ b/f.txt
119
+ @@ -1,3 +1,3 @@
120
+ context`;
121
+ const html = renderDiff(diff);
122
+ expect(html).toContain('class="diff-hunk"');
123
+ expect(html).toContain("@@ -1,3 +1,3 @@");
124
+ });
125
+
126
+ it("renders diff --git header with diff-header class", () => {
127
+ const diff = `diff --git a/f.txt b/f.txt
128
+ index abc..def 100644
129
+ --- a/f.txt
130
+ +++ b/f.txt
131
+ @@ -1 +1 @@
132
+ -old
133
+ +new`;
134
+ const html = renderDiff(diff);
135
+ expect(html).toContain('class="diff-header"');
136
+ expect(html).toContain("diff --git");
137
+ });
138
+
139
+ it("renders context lines with diff-ctx class", () => {
140
+ const diff = `--- a/f.txt
141
+ +++ b/f.txt
142
+ @@ -1,3 +1,3 @@
143
+ context line
144
+ -old
145
+ +new`;
146
+ const html = renderDiff(diff);
147
+ expect(html).toContain('class="diff-ctx"');
148
+ expect(html).toContain("context line");
149
+ });
150
+
151
+ it("escapes HTML entities in diff content", () => {
152
+ const diff = `--- a/f.html
153
+ +++ b/f.html
154
+ @@ -1 +1 @@
155
+ -<div class="old">old</div>
156
+ +<div class="new">new</div>`;
157
+ const html = renderDiff(diff);
158
+ expect(html).toContain("&lt;div");
159
+ expect(html).toContain("&gt;");
160
+ expect(html).toContain("&quot;");
161
+ expect(html).not.toContain('<div class="old">');
162
+ expect(html).not.toContain('<div class="new">');
163
+ });
164
+
165
+ it("includes line numbers", () => {
166
+ const diff = `--- a/f.txt
167
+ +++ b/f.txt
168
+ @@ -1,2 +1,2 @@
169
+ same
170
+ -old
171
+ +new`;
172
+ const html = renderDiff(diff);
173
+ expect(html).toContain('class="diff-line-num"');
174
+ });
175
+
176
+ it("wraps output in a diff container", () => {
177
+ const diff = `--- a/f.txt
178
+ +++ b/f.txt
179
+ @@ -1 +1 @@
180
+ -a
181
+ +b`;
182
+ const html = renderDiff(diff);
183
+ expect(html).toContain('class="diff-container"');
184
+ });
185
+ });
186
+
187
+ // ── renderMarkdown ─────────────────────────────────────────────
188
+
189
+ describe("renderMarkdown", () => {
190
+ it("renders # headers to <h1>", () => {
191
+ expect(renderMarkdown("# Title")).toContain("<h1>Title</h1>");
192
+ });
193
+
194
+ it("renders ## headers to <h2>", () => {
195
+ expect(renderMarkdown("## Subtitle")).toContain("<h2>Subtitle</h2>");
196
+ });
197
+
198
+ it("renders ### headers to <h3>", () => {
199
+ expect(renderMarkdown("### Section")).toContain("<h3>Section</h3>");
200
+ });
201
+
202
+ it("renders **bold** to <strong>", () => {
203
+ const html = renderMarkdown("This is **bold** text");
204
+ expect(html).toContain("<strong>bold</strong>");
205
+ });
206
+
207
+ it("renders *italic* to <em>", () => {
208
+ const html = renderMarkdown("This is *italic* text");
209
+ expect(html).toContain("<em>italic</em>");
210
+ });
211
+
212
+ it("renders `inline code` to <code>", () => {
213
+ const html = renderMarkdown("Use `console.log` here");
214
+ expect(html).toContain("<code>console.log</code>");
215
+ });
216
+
217
+ it("renders fenced code blocks with language class", () => {
218
+ const md = "```js\nconst x = 1;\n```";
219
+ const html = renderMarkdown(md);
220
+ expect(html).toContain("<pre>");
221
+ expect(html).toContain('<code class="language-js">');
222
+ expect(html).toContain("const x = 1;");
223
+ });
224
+
225
+ it("renders fenced code blocks without language", () => {
226
+ const md = "```\nhello world\n```";
227
+ const html = renderMarkdown(md);
228
+ expect(html).toContain("<pre>");
229
+ expect(html).toContain("<code>");
230
+ expect(html).toContain("hello world");
231
+ });
232
+
233
+ it("renders unordered lists", () => {
234
+ const md = "- item one\n- item two\n- item three";
235
+ const html = renderMarkdown(md);
236
+ expect(html).toContain("<ul>");
237
+ expect(html).toContain("<li>item one</li>");
238
+ expect(html).toContain("<li>item two</li>");
239
+ expect(html).toContain("</ul>");
240
+ });
241
+
242
+ it("renders ordered lists", () => {
243
+ const md = "1. first\n2. second\n3. third";
244
+ const html = renderMarkdown(md);
245
+ expect(html).toContain("<ol>");
246
+ expect(html).toContain("<li>first</li>");
247
+ expect(html).toContain("<li>second</li>");
248
+ expect(html).toContain("</ol>");
249
+ });
250
+
251
+ it("renders [text](url) links", () => {
252
+ const md = "Visit [Example](https://example.com) now";
253
+ const html = renderMarkdown(md);
254
+ expect(html).toContain('<a href="https://example.com"');
255
+ expect(html).toContain(">Example</a>");
256
+ });
257
+
258
+ it("renders > blockquotes", () => {
259
+ const md = "> This is a quote";
260
+ const html = renderMarkdown(md);
261
+ expect(html).toContain("<blockquote>");
262
+ expect(html).toContain("This is a quote");
263
+ expect(html).toContain("</blockquote>");
264
+ });
265
+
266
+ it("renders --- as <hr>", () => {
267
+ const md = "above\n\n---\n\nbelow";
268
+ const html = renderMarkdown(md);
269
+ expect(html).toContain("<hr>");
270
+ });
271
+
272
+ it("escapes HTML entities in content", () => {
273
+ const md = "Use `<div>` in your **<template>**";
274
+ const html = renderMarkdown(md);
275
+ expect(html).toContain("&lt;div&gt;");
276
+ expect(html).toContain("&lt;template&gt;");
277
+ // Should not contain raw HTML tags from user content
278
+ expect(html).not.toContain("<div>");
279
+ expect(html).not.toContain("<template>");
280
+ });
281
+
282
+ it("renders paragraphs for plain text blocks", () => {
283
+ const md = "First paragraph.\n\nSecond paragraph.";
284
+ const html = renderMarkdown(md);
285
+ expect(html).toContain("<p>First paragraph.</p>");
286
+ expect(html).toContain("<p>Second paragraph.</p>");
287
+ });
288
+
289
+ it("wraps output in rendered-md container", () => {
290
+ const html = renderMarkdown("# Hello");
291
+ expect(html).toContain('class="rendered-md"');
292
+ });
293
+ });
294
+
295
+ // ── renderAnsi ─────────────────────────────────────────────────
296
+
297
+ describe("renderAnsi", () => {
298
+ it("converts red ANSI to span with color", () => {
299
+ const text = "\x1b[31mError\x1b[0m";
300
+ const html = renderAnsi(text);
301
+ expect(html).toContain('style="color:#cc0000"');
302
+ expect(html).toContain("Error");
303
+ });
304
+
305
+ it("converts green ANSI to span with color", () => {
306
+ const text = "\x1b[32mSuccess\x1b[0m";
307
+ const html = renderAnsi(text);
308
+ expect(html).toContain('style="color:#00cc00"');
309
+ expect(html).toContain("Success");
310
+ });
311
+
312
+ it("converts bold ANSI to span with class", () => {
313
+ const text = "\x1b[1mBold text\x1b[0m";
314
+ const html = renderAnsi(text);
315
+ expect(html).toContain('class="ansi-bold"');
316
+ expect(html).toContain("Bold text");
317
+ });
318
+
319
+ it("converts dim ANSI to span with class", () => {
320
+ const text = "\x1b[2mDim text\x1b[0m";
321
+ const html = renderAnsi(text);
322
+ expect(html).toContain('class="ansi-dim"');
323
+ expect(html).toContain("Dim text");
324
+ });
325
+
326
+ it("converts italic ANSI to span with class", () => {
327
+ const text = "\x1b[3mItalic text\x1b[0m";
328
+ const html = renderAnsi(text);
329
+ expect(html).toContain('class="ansi-italic"');
330
+ expect(html).toContain("Italic text");
331
+ });
332
+
333
+ it("converts underline ANSI to span with class", () => {
334
+ const text = "\x1b[4mUnderline\x1b[0m";
335
+ const html = renderAnsi(text);
336
+ expect(html).toContain('class="ansi-underline"');
337
+ expect(html).toContain("Underline");
338
+ });
339
+
340
+ it("handles bright/intense colors", () => {
341
+ const text = "\x1b[91mBright red\x1b[0m";
342
+ const html = renderAnsi(text);
343
+ expect(html).toContain("Bright red");
344
+ // Should have a color style (bright red)
345
+ expect(html).toContain("style=");
346
+ expect(html).toContain("color:");
347
+ });
348
+
349
+ it("strips non-SGR escape sequences", () => {
350
+ // Cursor movement (CSI H), erase (CSI J)
351
+ const text = "\x1b[2J\x1b[HHello\x1b[31m World\x1b[0m";
352
+ const html = renderAnsi(text);
353
+ expect(html).toContain("Hello");
354
+ expect(html).toContain("World");
355
+ // Non-SGR sequences should be stripped
356
+ expect(html).not.toContain("\x1b");
357
+ });
358
+
359
+ it("handles reset correctly", () => {
360
+ const text = "\x1b[31mred\x1b[0m normal";
361
+ const html = renderAnsi(text);
362
+ expect(html).toContain("red");
363
+ expect(html).toContain("normal");
364
+ // "normal" should not be inside a colored span
365
+ // The span for "red" should be closed before "normal"
366
+ });
367
+
368
+ it("escapes HTML entities in ANSI text", () => {
369
+ const text = "\x1b[31m<script>alert('xss')</script>\x1b[0m";
370
+ const html = renderAnsi(text);
371
+ expect(html).toContain("&lt;script&gt;");
372
+ expect(html).not.toContain("<script>");
373
+ });
374
+
375
+ it("handles combined attributes (bold + color)", () => {
376
+ const text = "\x1b[1;33mBold yellow\x1b[0m";
377
+ const html = renderAnsi(text);
378
+ expect(html).toContain("Bold yellow");
379
+ // Should have both bold class and yellow color
380
+ expect(html).toContain("ansi-bold");
381
+ expect(html).toContain("color:");
382
+ });
383
+
384
+ it("returns plain text when no ANSI sequences present", () => {
385
+ const text = "Just plain text";
386
+ const html = renderAnsi(text);
387
+ expect(html).toContain("Just plain text");
388
+ // Should not contain any spans
389
+ expect(html).not.toContain("<span");
390
+ });
391
+ });