Skip to content
Source
Sign inSign up
Open History to see the last commit

GnomeCaptureSetup.test.ts

155 lines6.4 KB

1// @effect-diagnostics nodeBuiltinImport:off -- Native extension installation uses isolated filesystem fixtures.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeEvents from "node:events";
6import type { Message, MessageBus } from "dbus-next";
7import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
8import { GnomeCaptureSetup, installGnomeCaptureBundle } from "./GnomeCaptureSetup.ts";
9import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts";
10​
11let dataHome: string;
12const bundle = NodePath.resolve(import.meta.dirname, "../../gnome-extension");
13const installedPath = () => NodePath.join(dataHome, "gnome-shell/extensions", GNOME_CAPTURE_UUID);
14beforeEach(async () => {
15 dataHome = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-gnome-setup-test-"));
16});
17afterEach(async () => {
18 await NodeFSP.rm(dataHome, { recursive: true, force: true });
19});
20​
21function fixture(
22 options: {
23 state?: number;
24 version?: number;
25 shell?: string;
26 enabled?: boolean;
27 accepted?: boolean;
28 error?: string;
29 } = {},
30) {
31 const call = vi.fn(async (message: Message) => ({
32 body: [
33 message.member === "GetAll"
34 ? {
35 ShellVersion: { value: options.shell ?? "50.0" },
36 UserExtensionsEnabled: { value: options.enabled ?? true },
37 }
38 : message.member === "GetExtensionInfo"
39 ? {
40 ...(options.state === undefined ? {} : { state: { value: options.state } }),
41 ...(options.version === undefined ? {} : { version: { value: options.version } }),
42 error: { value: options.error ?? "" },
43 }
44 : (options.accepted ?? true),
45 ],
46 }));
47 const disconnect = vi.fn();
48 const bus = Object.assign(new NodeEvents.EventEmitter(), { call, disconnect });
49 return {
50 setup: new GnomeCaptureSetup({ bundle, dataHome }, bus as unknown as MessageBus),
51 call,
52 disconnect,
53 bus,
54 };
56​
57it("installs exactly the bundled payload offline and leaves other extensions alone", async () => {
58 const other = NodePath.join(dataHome, "gnome-shell/extensions/other@example");
59 await NodeFSP.mkdir(other, { recursive: true });
60 await NodeFSP.writeFile(NodePath.join(other, "keep.txt"), "keep");
61 await installGnomeCaptureBundle({ bundle, dataHome });
62 expect((await NodeFSP.readdir(installedPath())).sort()).toEqual([...GNOME_CAPTURE_FILES].sort());
63 for (const file of GNOME_CAPTURE_FILES) {
64 expect(await NodeFSP.readFile(NodePath.join(installedPath(), file))).toEqual(
65 await NodeFSP.readFile(NodePath.join(bundle, file)),
66 );
67 }
68 expect(await NodeFSP.readFile(NodePath.join(other, "keep.txt"), "utf8")).toBe("keep");
69});
70​
71it("preserves the replaced extension as a recoverable backup", async () => {
72 await installGnomeCaptureBundle({ bundle, dataHome });
73 await NodeFSP.writeFile(NodePath.join(installedPath(), "custom.txt"), "local change");
74 await installGnomeCaptureBundle({ bundle, dataHome });
75 const backups = NodePath.join(dataHome, "t3code/extension-backups");
76 const [backup] = await NodeFSP.readdir(backups);
77 expect(
78 await NodeFSP.readFile(
79 NodePath.join(backups, backup!, GNOME_CAPTURE_UUID, "custom.txt"),
80 "utf8",
81 ),
82 ).toBe("local change");
83});
84​
85it("does not damage an installed extension if a bundled file is missing", async () => {
86 await installGnomeCaptureBundle({ bundle, dataHome });
87 const incomplete = NodePath.join(dataHome, "incomplete");
88 await NodeFSP.mkdir(incomplete);
89 await NodeFSP.copyFile(
90 NodePath.join(bundle, "metadata.json"),
91 NodePath.join(incomplete, "metadata.json"),
92 );
93 await expect(installGnomeCaptureBundle({ bundle: incomplete, dataHome })).rejects.toThrow();
94 expect(await NodeFSP.readFile(NodePath.join(installedPath(), "extension.js"))).toEqual(
95 await NodeFSP.readFile(NodePath.join(bundle, "extension.js")),
96 );
97 expect(await NodeFSP.readdir(NodePath.dirname(installedPath()))).toEqual([GNOME_CAPTURE_UUID]);
98});
99​
100it("refuses to replace symlinks or downgrade a newer extension", async () => {
101 await NodeFSP.mkdir(NodePath.dirname(installedPath()), { recursive: true });
102 await NodeFSP.symlink(bundle, installedPath());
103 await expect(installGnomeCaptureBundle({ bundle, dataHome })).rejects.toThrow(
104 "regular directory",
105 );
106 await NodeFSP.unlink(installedPath());
107 await installGnomeCaptureBundle({ bundle, dataHome });
108 await NodeFSP.writeFile(
109 NodePath.join(installedPath(), "metadata.json"),
110 JSON.stringify({ uuid: GNOME_CAPTURE_UUID, version: 99, "shell-version": ["50"] }),
111 );
112 await expect(installGnomeCaptureBundle({ bundle, dataHome })).rejects.toThrow("newer extension");
113});
114​
115it.each([
116 [{ state: 1, version: 3 }, "enabled"],
117 [{ state: 1, version: 2, shell: "51.0" }, "update-required"],
118 [{ state: 2, version: 3, enabled: false }, "extensions-disabled"],
119 [{ shell: "52.0" }, "unsupported"],
120] as const)("reports GNOME setup state %j", async (options, status) => {
121 const { setup, call } = fixture(options);
122 expect((await setup.state()).status).toBe(status);
123 expect(call.mock.calls.map(([message]) => message.member).sort()).toEqual([
124 "GetAll",
125 "GetExtensionInfo",
126 ]);
127 setup.close();
128});
129​
130it("requires login for local installs and updates until the new version is loaded", async () => {
131 await installGnomeCaptureBundle({ bundle, dataHome });
132 for (const options of [{}, { state: 1, version: 1 }]) {
133 const { setup } = fixture(options);
134 expect((await setup.state()).status).toBe("restart-required");
135 setup.close();
136 }
137 const { setup } = fixture({ state: 2, version: 3 });
138 expect((await setup.state()).status).toBe("disabled");
139 setup.close();
140});
141​
142it("never changes the global user-extensions preference", async () => {
143 const { setup, call } = fixture({ state: 2, version: 3, enabled: false });
144 await expect(setup.perform("enable-extension")).rejects.toThrow("GNOME has disabled");
145 expect(call.mock.calls.every(([message]) => message.member.startsWith("Get"))).toBe(true);
146 setup.close();
147});
148​
149it("surfaces desktop rejection and disconnect as actionable failures", async () => {
150 const { setup, bus } = fixture({ state: 2, version: 3, accepted: false });
151 await expect(setup.perform("enable-extension")).rejects.toThrow("Sign out");
152 bus.emit("error", new Error("Session bus disconnected"));
153 expect(await setup.state()).toEqual({ status: "error", message: "Session bus disconnected" });
154 setup.close();
155});