@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- 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("<div");
|
|
159
|
+
expect(html).toContain(">");
|
|
160
|
+
expect(html).toContain(""");
|
|
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("<div>");
|
|
276
|
+
expect(html).toContain("<template>");
|
|
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("<script>");
|
|
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
|
+
});
|