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 {
9 GnomeCaptureSetup,
10 installGnomeCaptureBundle,
11 isGnomeCaptureSession,
12} from "./GnomeCaptureSetup.ts";
13import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts";
14
15let dataHome: string;
16const bundle = NodePath.resolve(import.meta.dirname, "../../gnome-extension");
17const installedPath = () => NodePath.join(dataHome, "gnome-shell/extensions", GNOME_CAPTURE_UUID);
18beforeEach(async () => {
19 dataHome = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-gnome-setup-test-"));
20});
21afterEach(async () => {
22 await NodeFSP.rm(dataHome, { recursive: true, force: true });
23});
24
25function fixture(
26 options: {
27 state?: number;
28 version?: number;
29 shell?: string;
30 enabled?: boolean;
31 accepted?: boolean;
32 error?: string;
33 } = {},
34) {
35 const call = vi.fn(async (message: Message) => ({
36 body: [
37 message.member === "GetAll"
38 ? {
39 ShellVersion: { value: options.shell ?? "50.0" },
40 UserExtensionsEnabled: { value: options.enabled ?? true },
41 }
42 : message.member === "GetExtensionInfo"
43 ? {
44 ...(options.state === undefined ? {} : { state: { value: options.state } }),
45 ...(options.version === undefined ? {} : { version: { value: options.version } }),
46 error: { value: options.error ?? "" },
47 }
48 : (options.accepted ?? true),
49 ],
50 }));
51 const disconnect = vi.fn();
52 const bus = Object.assign(new NodeEvents.EventEmitter(), { call, disconnect });
53 return {
54 setup: new GnomeCaptureSetup({ bundle, dataHome }, bus as unknown as MessageBus),
55 call,
56 disconnect,
57 bus,
58 };
59}
60
61it("installs exactly the bundled payload offline and leaves other extensions alone", async () => {
62 const other = NodePath.join(dataHome, "gnome-shell/extensions/other@example");
63 await NodeFSP.mkdir(other, { recursive: true });
64 await NodeFSP.writeFile(NodePath.join(other, "keep.txt"), "keep");
65 await installGnomeCaptureBundle({ bundle, dataHome });
66 expect((await NodeFSP.readdir(installedPath())).sort()).toEqual([...GNOME_CAPTURE_FILES].sort());
67 for (const file of GNOME_CAPTURE_FILES) {
68 expect(await NodeFSP.readFile(NodePath.join(installedPath(), file))).toEqual(
69 await NodeFSP.readFile(NodePath.join(bundle, file)),
70 );
71 }
72 expect(await NodeFSP.readFile(NodePath.join(other, "keep.txt"), "utf8")).toBe("keep");
73});
74
75it("preserves the replaced extension as a recoverable backup", async () => {
76 await installGnomeCaptureBundle({ bundle, dataHome });
77 await NodeFSP.writeFile(NodePath.join(installedPath(), "custom.txt"), "local change");
78 await installGnomeCaptureBundle({ bundle, dataHome });
79 const backups = NodePath.join(dataHome, "t3code/extension-backups");
80 const [backup] = await NodeFSP.readdir(backups);
81 expect(
82 await NodeFSP.readFile(
83 NodePath.join(backups, backup!, GNOME_CAPTURE_UUID, "custom.txt"),
84 "utf8",
85 ),
86 ).toBe("local change");
87});
88
89it("does not damage an installed extension if a bundled file is missing", async () => {
90 await installGnomeCaptureBundle({ bundle, dataHome });
91 const incomplete = NodePath.join(dataHome, "incomplete");
92 await NodeFSP.mkdir(incomplete);
93 await NodeFSP.copyFile(
94 NodePath.join(bundle, "metadata.json"),
95 NodePath.join(incomplete, "metadata.json"),
96 );
97 await expect(installGnomeCaptureBundle({ bundle: incomplete, dataHome })).rejects.toThrow();
98 expect(await NodeFSP.readFile(NodePath.join(installedPath(), "extension.js"))).toEqual(
99 await NodeFSP.readFile(NodePath.join(bundle, "extension.js")),
100 );
101 expect(await NodeFSP.readdir(NodePath.dirname(installedPath()))).toEqual([GNOME_CAPTURE_UUID]);
102});
103
104it("refuses to replace symlinks or downgrade a newer extension", async () => {
105 await NodeFSP.mkdir(NodePath.dirname(installedPath()), { recursive: true });
106 await NodeFSP.symlink(bundle, installedPath());
107 await expect(installGnomeCaptureBundle({ bundle, dataHome })).rejects.toThrow(
108 "regular directory",
109 );
110 await NodeFSP.unlink(installedPath());
111 await installGnomeCaptureBundle({ bundle, dataHome });
112 await NodeFSP.writeFile(
113 NodePath.join(installedPath(), "metadata.json"),
114 JSON.stringify({ uuid: GNOME_CAPTURE_UUID, version: 99, "shell-version": ["50"] }),
115 );
116 await expect(installGnomeCaptureBundle({ bundle, dataHome })).rejects.toThrow("newer extension");
117});
118
119it.each([
120 [{}, "not-installed"],
121 [{ state: 2, version: 2 }, "disabled"],
122 [{ state: 1, version: 2 }, "enabled"],
123 [{ state: 1, version: 1 }, "update-required"],
124 [{ state: 2, version: 2, enabled: false }, "extensions-disabled"],
125 [{ state: 3, version: 2, error: "Load failed" }, "error"],
126 [{ shell: "51.0" }, "unsupported"],
127] as const)("reports GNOME setup state %j", async (options, status) => {
128 const { setup, call } = fixture(options);
129 expect((await setup.state()).status).toBe(status);
130 expect(call.mock.calls.map(([message]) => message.member).sort()).toEqual([
131 "GetAll",
132 "GetExtensionInfo",
133 ]);
134 setup.close();
135});
136
137it("requires login for local installs and updates until the new version is loaded", async () => {
138 await installGnomeCaptureBundle({ bundle, dataHome });
139 for (const options of [{}, { state: 1, version: 1 }]) {
140 const { setup } = fixture(options);
141 expect((await setup.state()).status).toBe("restart-required");
142 setup.close();
143 }
144 const { setup } = fixture({ state: 2, version: 2 });
145 expect((await setup.state()).status).toBe("disabled");
146 setup.close();
147});
148
149it("installs only on explicit action without invoking remote installation", async () => {
150 const { setup, call } = fixture();
151 await setup.perform("install-extension");
152 expect(await NodeFSP.readdir(installedPath())).toHaveLength(GNOME_CAPTURE_FILES.length);
153 expect(
154 call.mock.calls.every(([message]) => ["GetAll", "GetExtensionInfo"].includes(message.member)),
155 ).toBe(true);
156 setup.close();
157});
158
159it("enables and disables only the capture UUID", async () => {
160 const { setup, call } = fixture({ state: 2, version: 2 });
161 await setup.perform("enable-extension");
162 await setup.perform("disable-extension");
163 for (const member of ["EnableExtension", "DisableExtension"]) {
164 expect(call.mock.calls.find(([message]) => message.member === member)?.[0]).toMatchObject({
165 destination: "org.gnome.Shell",
166 path: "/org/gnome/Shell",
167 signature: "s",
168 body: [GNOME_CAPTURE_UUID],
169 });
170 }
171 setup.close();
172});
173
174it("never changes the global user-extensions preference", async () => {
175 const { setup, call } = fixture({ state: 2, version: 2, enabled: false });
176 await expect(setup.perform("enable-extension")).rejects.toThrow("GNOME has disabled");
177 expect(call.mock.calls.every(([message]) => message.member.startsWith("Get"))).toBe(true);
178 setup.close();
179});
180
181it("surfaces desktop rejection and disconnect as actionable failures", async () => {
182 const { setup, bus } = fixture({ state: 2, version: 2, accepted: false });
183 await expect(setup.perform("enable-extension")).rejects.toThrow("Sign out");
184 bus.emit("error", new Error("Session bus disconnected"));
185 expect(await setup.state()).toEqual({ status: "error", message: "Session bus disconnected" });
186 setup.close();
187});
188
189it("does not offer host extension installation in another desktop or sandbox", () => {
190 expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBe(true);
191 expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "niri", GDMSESSION: "gnome" })).toBe(false);
192 expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "GNOME", FLATPAK_ID: "com.t3" })).toBe(false);
193});