icebear0828 commited on
Commit
325c8ea
·
1 Parent(s): a5b6a79

test: add electron package tests (33 cases)

Browse files

- prepare-pack: copy/clean behavior, idempotency, missing dirs
- auto-updater: state machine transitions, timer scheduling, throttle
- build: esbuild smoke test, output validation, export verification
- builder-config: YAML structure, path validation, version sync

packages/electron/__tests__/auto-updater.test.ts ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for electron/auto-updater.ts
3
+ *
4
+ * Mocks electron-updater and electron dialog to test state transitions
5
+ * without a real Electron runtime.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
9
+ import { EventEmitter } from "events";
10
+
11
+ // ── Mocks ────────────────────────────────────────────────────────────
12
+
13
+ // Create a mock autoUpdater as an EventEmitter with methods
14
+ const mockAutoUpdater = Object.assign(new EventEmitter(), {
15
+ autoDownload: true,
16
+ autoInstallOnAppQuit: false,
17
+ allowPrerelease: false,
18
+ checkForUpdates: vi.fn().mockResolvedValue(undefined),
19
+ downloadUpdate: vi.fn().mockResolvedValue(undefined),
20
+ quitAndInstall: vi.fn(),
21
+ });
22
+
23
+ vi.mock("electron-updater", () => ({
24
+ autoUpdater: mockAutoUpdater,
25
+ }));
26
+
27
+ vi.mock("electron", () => ({
28
+ BrowserWindow: class {},
29
+ dialog: {
30
+ showMessageBox: vi.fn().mockResolvedValue({ response: 1 }), // "Later" by default
31
+ },
32
+ }));
33
+
34
+ // Import after mocks are set up
35
+ const { getAutoUpdateState, initAutoUpdater, stopAutoUpdater } = await import(
36
+ "../electron/auto-updater.js"
37
+ );
38
+
39
+ describe("auto-updater state machine", () => {
40
+ const mockOptions = {
41
+ getMainWindow: vi.fn().mockReturnValue(null),
42
+ rebuildTrayMenu: vi.fn(),
43
+ };
44
+
45
+ beforeEach(() => {
46
+ vi.useFakeTimers();
47
+ vi.clearAllMocks();
48
+ mockAutoUpdater.removeAllListeners();
49
+ });
50
+
51
+ afterEach(() => {
52
+ stopAutoUpdater();
53
+ vi.useRealTimers();
54
+ });
55
+
56
+ it("initial state is idle", () => {
57
+ const state = getAutoUpdateState();
58
+ expect(state.checking).toBe(false);
59
+ expect(state.updateAvailable).toBe(false);
60
+ expect(state.downloading).toBe(false);
61
+ expect(state.downloaded).toBe(false);
62
+ expect(state.version).toBeNull();
63
+ expect(state.error).toBeNull();
64
+ });
65
+
66
+ it("configures autoUpdater on init", () => {
67
+ initAutoUpdater(mockOptions);
68
+
69
+ expect(mockAutoUpdater.autoDownload).toBe(false);
70
+ expect(mockAutoUpdater.autoInstallOnAppQuit).toBe(true);
71
+ expect(mockAutoUpdater.allowPrerelease).toBe(false);
72
+ });
73
+
74
+ it("schedules initial check after 30s delay", () => {
75
+ initAutoUpdater(mockOptions);
76
+
77
+ expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled();
78
+
79
+ vi.advanceTimersByTime(30_000);
80
+
81
+ expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it("schedules periodic check every 4 hours", () => {
85
+ initAutoUpdater(mockOptions);
86
+
87
+ // Initial delay
88
+ vi.advanceTimersByTime(30_000);
89
+ expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
90
+
91
+ // 4 hours later
92
+ vi.advanceTimersByTime(4 * 60 * 60 * 1000);
93
+ expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalledTimes(2);
94
+ });
95
+
96
+ it("transitions to checking state", () => {
97
+ initAutoUpdater(mockOptions);
98
+
99
+ mockAutoUpdater.emit("checking-for-update");
100
+
101
+ const state = getAutoUpdateState();
102
+ expect(state.checking).toBe(true);
103
+ expect(state.error).toBeNull();
104
+ });
105
+
106
+ it("transitions to update-available state", () => {
107
+ initAutoUpdater(mockOptions);
108
+
109
+ mockAutoUpdater.emit("update-available", { version: "2.0.0" });
110
+
111
+ const state = getAutoUpdateState();
112
+ expect(state.checking).toBe(false);
113
+ expect(state.updateAvailable).toBe(true);
114
+ expect(state.version).toBe("2.0.0");
115
+ expect(mockOptions.rebuildTrayMenu).toHaveBeenCalled();
116
+ });
117
+
118
+ it("transitions to update-not-available state", () => {
119
+ initAutoUpdater(mockOptions);
120
+
121
+ mockAutoUpdater.emit("checking-for-update");
122
+ mockAutoUpdater.emit("update-not-available");
123
+
124
+ const state = getAutoUpdateState();
125
+ expect(state.checking).toBe(false);
126
+ expect(state.updateAvailable).toBe(false);
127
+ });
128
+
129
+ it("tracks download progress", () => {
130
+ initAutoUpdater(mockOptions);
131
+
132
+ mockAutoUpdater.emit("download-progress", { percent: 50 });
133
+
134
+ const state = getAutoUpdateState();
135
+ expect(state.downloading).toBe(true);
136
+ expect(state.progress).toBe(50);
137
+ });
138
+
139
+ it("throttles tray rebuild to every 10% increment", () => {
140
+ initAutoUpdater(mockOptions);
141
+ mockOptions.rebuildTrayMenu.mockClear();
142
+
143
+ mockAutoUpdater.emit("download-progress", { percent: 5 });
144
+ expect(mockOptions.rebuildTrayMenu).not.toHaveBeenCalled();
145
+
146
+ mockAutoUpdater.emit("download-progress", { percent: 15 });
147
+ expect(mockOptions.rebuildTrayMenu).toHaveBeenCalledTimes(1);
148
+
149
+ mockOptions.rebuildTrayMenu.mockClear();
150
+ mockAutoUpdater.emit("download-progress", { percent: 20 });
151
+ expect(mockOptions.rebuildTrayMenu).not.toHaveBeenCalled();
152
+
153
+ mockAutoUpdater.emit("download-progress", { percent: 100 });
154
+ expect(mockOptions.rebuildTrayMenu).toHaveBeenCalledTimes(1);
155
+ });
156
+
157
+ it("transitions to downloaded state", () => {
158
+ initAutoUpdater(mockOptions);
159
+
160
+ mockAutoUpdater.emit("update-downloaded", { version: "2.0.0" });
161
+
162
+ const state = getAutoUpdateState();
163
+ expect(state.downloading).toBe(false);
164
+ expect(state.downloaded).toBe(true);
165
+ expect(state.progress).toBe(100);
166
+ expect(mockOptions.rebuildTrayMenu).toHaveBeenCalled();
167
+ });
168
+
169
+ it("handles errors gracefully", () => {
170
+ initAutoUpdater(mockOptions);
171
+
172
+ mockAutoUpdater.emit("error", new Error("Network timeout"));
173
+
174
+ const state = getAutoUpdateState();
175
+ expect(state.checking).toBe(false);
176
+ expect(state.downloading).toBe(false);
177
+ expect(state.error).toBe("Network timeout");
178
+ expect(mockOptions.rebuildTrayMenu).toHaveBeenCalled();
179
+ });
180
+
181
+ it("stopAutoUpdater clears all timers", () => {
182
+ initAutoUpdater(mockOptions);
183
+
184
+ stopAutoUpdater();
185
+
186
+ // Advance past both initial delay and periodic interval
187
+ vi.advanceTimersByTime(5 * 60 * 60 * 1000);
188
+
189
+ // checkForUpdates should not have been called (timers cleared)
190
+ expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("returns a copy of state (not reference)", () => {
194
+ const state1 = getAutoUpdateState();
195
+ const state2 = getAutoUpdateState();
196
+
197
+ expect(state1).not.toBe(state2);
198
+ expect(state1).toEqual(state2);
199
+ });
200
+ });
packages/electron/__tests__/build.test.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Smoke test for esbuild bundling.
3
+ *
4
+ * Verifies that electron/build.mjs produces valid output files
5
+ * with the expected exports.
6
+ */
7
+
8
+ import { describe, it, expect, afterAll } from "vitest";
9
+ import { existsSync, rmSync, statSync } from "fs";
10
+ import { resolve } from "path";
11
+ import { execFileSync } from "child_process";
12
+
13
+ const PKG_DIR = resolve(import.meta.dirname, "..");
14
+ const DIST = resolve(PKG_DIR, "dist-electron");
15
+
16
+ describe("electron build (esbuild)", () => {
17
+ // Build once for all tests in this suite
18
+ const buildOnce = (() => {
19
+ let built = false;
20
+ return () => {
21
+ if (built) return;
22
+ execFileSync("node", ["electron/build.mjs"], {
23
+ cwd: PKG_DIR,
24
+ timeout: 30_000,
25
+ });
26
+ built = true;
27
+ };
28
+ })();
29
+
30
+ afterAll(() => {
31
+ // Clean up build output
32
+ if (existsSync(DIST)) {
33
+ rmSync(DIST, { recursive: true });
34
+ }
35
+ });
36
+
37
+ it("produces main.cjs (Electron main process)", () => {
38
+ buildOnce();
39
+ const mainCjs = resolve(DIST, "main.cjs");
40
+ expect(existsSync(mainCjs)).toBe(true);
41
+ expect(statSync(mainCjs).size).toBeGreaterThan(1000);
42
+ });
43
+
44
+ it("produces server.mjs (backend server bundle)", () => {
45
+ buildOnce();
46
+ const serverMjs = resolve(DIST, "server.mjs");
47
+ expect(existsSync(serverMjs)).toBe(true);
48
+ expect(statSync(serverMjs).size).toBeGreaterThan(1000);
49
+ });
50
+
51
+ it("produces sourcemaps for both bundles", () => {
52
+ buildOnce();
53
+ expect(existsSync(resolve(DIST, "main.cjs.map"))).toBe(true);
54
+ expect(existsSync(resolve(DIST, "server.mjs.map"))).toBe(true);
55
+ });
56
+
57
+ it("server.mjs exports setPaths and startServer", async () => {
58
+ buildOnce();
59
+ const serverMjs = resolve(DIST, "server.mjs");
60
+ const mod = await import(serverMjs);
61
+ expect(typeof mod.setPaths).toBe("function");
62
+ expect(typeof mod.startServer).toBe("function");
63
+ });
64
+ });
packages/electron/__tests__/builder-config.test.ts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Validates electron-builder.yml configuration.
3
+ *
4
+ * Ensures all referenced files/directories exist and the config
5
+ * is structurally valid — catches the kind of path issues that
6
+ * have historically broken electron releases.
7
+ */
8
+
9
+ import { describe, it, expect } from "vitest";
10
+ import { readFileSync, existsSync } from "fs";
11
+ import { resolve } from "path";
12
+ import yaml from "js-yaml";
13
+
14
+ const PKG_DIR = resolve(import.meta.dirname, "..");
15
+ const ROOT_DIR = resolve(PKG_DIR, "..", "..");
16
+
17
+ interface BuilderConfig {
18
+ appId: string;
19
+ productName: string;
20
+ electronVersion: string;
21
+ publish: { provider: string; owner: string; repo: string };
22
+ directories: { output: string };
23
+ files: Array<string | { from: string; to: string; filter?: string[] }>;
24
+ asarUnpack: string[];
25
+ extraResources: Array<{ from: string; to: string; filter?: string[] }>;
26
+ win: { target: Array<{ target: string; arch: string[] }>; icon: string };
27
+ mac: { target: Array<{ target: string; arch: string[] }>; icon: string };
28
+ linux: { target: Array<{ target: string; arch: string[] }>; icon: string };
29
+ }
30
+
31
+ const config = yaml.load(
32
+ readFileSync(resolve(PKG_DIR, "electron-builder.yml"), "utf-8"),
33
+ ) as BuilderConfig;
34
+
35
+ describe("electron-builder.yml", () => {
36
+ it("has valid YAML structure", () => {
37
+ expect(config.appId).toBe("com.codex-proxy.app");
38
+ expect(config.productName).toBe("Codex Proxy");
39
+ expect(config.electronVersion).toBeDefined();
40
+ });
41
+
42
+ it("has valid publish config", () => {
43
+ expect(config.publish.provider).toBe("github");
44
+ expect(config.publish.owner).toBeDefined();
45
+ expect(config.publish.repo).toBeDefined();
46
+ });
47
+
48
+ it("references existing icon file", () => {
49
+ const iconPath = resolve(PKG_DIR, config.mac.icon);
50
+ expect(existsSync(iconPath)).toBe(true);
51
+ });
52
+
53
+ it("icon file is referenced consistently across platforms", () => {
54
+ expect(config.win.icon).toBe(config.mac.icon);
55
+ expect(config.linux.icon).toBe(config.mac.icon);
56
+ });
57
+
58
+ it("files list includes dist-electron bundle", () => {
59
+ const hasDistElectron = config.files.some(
60
+ (f) => typeof f === "string" && f.includes("dist-electron"),
61
+ );
62
+ expect(hasDistElectron).toBe(true);
63
+ });
64
+
65
+ it("files list includes config, public, public-desktop globs", () => {
66
+ // After prepare-pack copies root dirs into packages/electron/,
67
+ // electron-builder picks them up via simple glob patterns
68
+ const globs = config.files.filter((f): f is string => typeof f === "string");
69
+ expect(globs).toContain("config/**/*");
70
+ expect(globs).toContain("public/**/*");
71
+ expect(globs).toContain("public-desktop/**/*");
72
+ });
73
+
74
+ it("root source directories for prepare-pack actually exist", () => {
75
+ // prepare-pack.mjs copies these from root before packing
76
+ const requiredDirs = ["config", "public", "bin"];
77
+ for (const dir of requiredDirs) {
78
+ const rootPath = resolve(ROOT_DIR, dir);
79
+ expect(
80
+ existsSync(rootPath),
81
+ `Root directory ${dir}/ should exist at ${rootPath}`,
82
+ ).toBe(true);
83
+ }
84
+ });
85
+
86
+ it("extraResources bin/ maps to correct root directory", () => {
87
+ const binResource = config.extraResources.find(
88
+ (r) => r.to === "bin/" || r.to === "bin",
89
+ );
90
+ expect(binResource).toBeDefined();
91
+ // bin/ is copied from root by prepare-pack before packing
92
+ const rootBin = resolve(ROOT_DIR, "bin");
93
+ expect(
94
+ existsSync(rootBin),
95
+ `Root bin/ directory should exist at ${rootBin}`,
96
+ ).toBe(true);
97
+ });
98
+
99
+ it("asarUnpack includes all runtime directories", () => {
100
+ const unpacked = config.asarUnpack;
101
+ expect(unpacked).toContain("config/**/*");
102
+ expect(unpacked).toContain("public/**/*");
103
+ expect(unpacked).toContain("public-desktop/**/*");
104
+ });
105
+
106
+ it("electronVersion matches installed version", () => {
107
+ const installedPkg = resolve(
108
+ ROOT_DIR,
109
+ "node_modules",
110
+ "electron",
111
+ "package.json",
112
+ );
113
+ if (existsSync(installedPkg)) {
114
+ const installed = JSON.parse(readFileSync(installedPkg, "utf-8")) as {
115
+ version: string;
116
+ };
117
+ expect(config.electronVersion).toBe(installed.version);
118
+ }
119
+ });
120
+
121
+ it("package.json has required fields for electron-builder", () => {
122
+ const pkg = JSON.parse(
123
+ readFileSync(resolve(PKG_DIR, "package.json"), "utf-8"),
124
+ ) as Record<string, unknown>;
125
+ expect(pkg.name).toBeDefined();
126
+ expect(pkg.version).toBeDefined();
127
+ expect(pkg.description).toBeDefined();
128
+ expect(pkg.main).toBe("dist-electron/main.cjs");
129
+ });
130
+ });
packages/electron/__tests__/prepare-pack.test.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for electron/prepare-pack.mjs
3
+ *
4
+ * Verifies that root-level resources (config/, public/, etc.) are correctly
5
+ * copied into packages/electron/ before electron-builder runs, and cleaned
6
+ * up afterward.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "fs";
11
+ import { resolve } from "path";
12
+ import { execFileSync } from "child_process";
13
+
14
+ const PKG_DIR = resolve(import.meta.dirname, "..");
15
+ const ROOT_DIR = resolve(PKG_DIR, "..", "..");
16
+ const SCRIPT = resolve(PKG_DIR, "electron", "prepare-pack.mjs");
17
+
18
+ // Directories that prepare-pack copies from root into packages/electron/
19
+ const DIRS = ["config", "public", "public-desktop", "bin"];
20
+
21
+ describe("prepare-pack.mjs", () => {
22
+ // Clean up any leftover copies before/after each test
23
+ function cleanCopies(): void {
24
+ for (const dir of DIRS) {
25
+ const dest = resolve(PKG_DIR, dir);
26
+ // Only remove if it's a copy (not the root original)
27
+ if (existsSync(dest) && resolve(dest) !== resolve(ROOT_DIR, dir)) {
28
+ rmSync(dest, { recursive: true });
29
+ }
30
+ }
31
+ }
32
+
33
+ beforeEach(cleanCopies);
34
+ afterEach(cleanCopies);
35
+
36
+ it("copies root directories into packages/electron/", () => {
37
+ execFileSync("node", [SCRIPT], { cwd: PKG_DIR });
38
+
39
+ for (const dir of DIRS) {
40
+ const rootDir = resolve(ROOT_DIR, dir);
41
+ const copyDir = resolve(PKG_DIR, dir);
42
+ if (existsSync(rootDir)) {
43
+ expect(existsSync(copyDir)).toBe(true);
44
+ }
45
+ }
46
+ });
47
+
48
+ it("copies config/ with correct content", () => {
49
+ execFileSync("node", [SCRIPT], { cwd: PKG_DIR });
50
+
51
+ const rootConfig = resolve(ROOT_DIR, "config", "default.yaml");
52
+ const copyConfig = resolve(PKG_DIR, "config", "default.yaml");
53
+
54
+ if (existsSync(rootConfig)) {
55
+ expect(existsSync(copyConfig)).toBe(true);
56
+ expect(readFileSync(copyConfig, "utf-8")).toBe(
57
+ readFileSync(rootConfig, "utf-8"),
58
+ );
59
+ }
60
+ });
61
+
62
+ it("--clean removes copied directories", () => {
63
+ // First copy
64
+ execFileSync("node", [SCRIPT], { cwd: PKG_DIR });
65
+
66
+ // Verify at least config exists
67
+ const copyConfig = resolve(PKG_DIR, "config");
68
+ expect(existsSync(copyConfig)).toBe(true);
69
+
70
+ // Then clean
71
+ execFileSync("node", [SCRIPT, "--clean"], { cwd: PKG_DIR });
72
+
73
+ for (const dir of DIRS) {
74
+ expect(existsSync(resolve(PKG_DIR, dir))).toBe(false);
75
+ }
76
+ });
77
+
78
+ it("skips missing root directories without error", () => {
79
+ // Create a temp directory that doesn't have all root dirs
80
+ // The script should warn but not throw
81
+ const result = execFileSync("node", [SCRIPT], {
82
+ cwd: PKG_DIR,
83
+ encoding: "utf-8",
84
+ stdio: ["pipe", "pipe", "pipe"],
85
+ });
86
+
87
+ // Should succeed without throwing
88
+ expect(result).toBeDefined();
89
+ });
90
+
91
+ it("--clean is idempotent (no error when dirs already absent)", () => {
92
+ // Clean without prior copy — should not throw
93
+ expect(() => {
94
+ execFileSync("node", [SCRIPT, "--clean"], { cwd: PKG_DIR });
95
+ }).not.toThrow();
96
+ });
97
+ });
vitest.config.ts CHANGED
@@ -16,6 +16,7 @@ export default defineConfig({
16
  "tests/unit/**/*.{test,spec}.ts",
17
  "tests/integration/**/*.{test,spec}.ts",
18
  "tests/e2e/**/*.{test,spec}.ts",
 
19
  ],
20
  },
21
  });
 
16
  "tests/unit/**/*.{test,spec}.ts",
17
  "tests/integration/**/*.{test,spec}.ts",
18
  "tests/e2e/**/*.{test,spec}.ts",
19
+ "packages/electron/__tests__/**/*.{test,spec}.ts",
20
  ],
21
  },
22
  });