1// @effect-diagnostics nodeBuiltinImport:off globalConsole:off
2// Launches a packaged desktop app against a seeded T3 home, opens a thread, and checks its
3// messages render. Release jobs run it so a build whose backend or bundle cannot start fails
4// before it is published.
5import * as NodeFS from "node:fs";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8import * as NodeTimersPromises from "node:timers/promises";
9import * as NodeUtil from "node:util";
10import { _electron, type ElectronApplication } from "playwright-core";
11
12// A snapshot from an older build on purpose: the packaged server must migrate it forward, as it
13// does for every upgrading user. It holds one project and two threads, nothing else.
14const FIXTURE_DB = NodePath.join(import.meta.dirname, "fixtures/desktop-launch/statev2.sqlite");
15const THREAD_TITLE = "Release launch check";
16const THREAD_MESSAGE = "The release opens and shows this thread.";
17const STEP_TIMEOUT_MS = 60_000;
18
19const { values } = NodeUtil.parseArgs({
20 options: {
21 executable: { type: "string" },
22 // An unpackaged app directory for a plain Electron executable, as PR CI runs it.
23 app: { type: "string" },
24 "artifacts-dir": { type: "string" },
25 },
26});
27const executable = values.executable;
28if (!executable)
29 throw new Error(
30 "Usage: smoke-desktop-app.ts --executable <app> [--app <dir>] [--artifacts-dir <dir>]",
31 );
32const artifactsDir = NodePath.resolve(values["artifacts-dir"] ?? "desktop-launch-check");
33
34const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-launch-"));
35const t3Home = NodePath.join(home, "t3");
36NodeFS.mkdirSync(NodePath.join(t3Home, "userdata"), { recursive: true });
37NodeFS.copyFileSync(FIXTURE_DB, NodePath.join(t3Home, "userdata/statev2.sqlite"));
38
39let step = "launching the app";
40let app: ElectronApplication | undefined;
41try {
42 app = await _electron.launch({
43 executablePath: executable,
44 // Linux CI runners do not allow the Chromium sandbox's user namespaces.
45 args: [...(values.app ? [NodePath.resolve(values.app)] : []), "--no-sandbox"],
46 // HOME stays real: on macOS a fake HOME has no login keychain, and safeStorage blocks the
47 // main process on a keychain dialog. macOS keeps its Electron profile in the runner's HOME.
48 env: {
49 ...process.env,
50 XDG_CONFIG_HOME: NodePath.join(home, ".config"),
51 XDG_DATA_HOME: NodePath.join(home, ".local/share"),
52 T3CODE_HOME: t3Home,
53 T3CODE_TELEMETRY_ENABLED: "false",
54 T3CODE_DISABLE_AUTO_UPDATE: "true",
55 },
56 timeout: STEP_TIMEOUT_MS,
57 });
58 // A backend that never starts leaves no window, or only the "could not start" window, so the
59 // thread wait below fails either way and its screenshot shows the startup error.
60 step = "waiting for the first window";
61 const page = await app.firstWindow({ timeout: STEP_TIMEOUT_MS });
62 try {
63 step = `finding "${THREAD_TITLE}" in the sidebar`;
64 const thread = page.locator("li[data-thread-item]", { hasText: THREAD_TITLE });
65 await thread.waitFor({ timeout: STEP_TIMEOUT_MS });
66 step = "opening the thread";
67 await thread.click();
68 await page.getByText(THREAD_MESSAGE).waitFor({ timeout: STEP_TIMEOUT_MS });
69 } catch (error) {
70 NodeFS.mkdirSync(artifactsDir, { recursive: true });
71 await page
72 .screenshot({ path: NodePath.join(artifactsDir, "window.png"), timeout: 10_000 })
73 .catch(() => {});
74 throw error;
75 }
76 console.log("Desktop launch check passed: the app opened and showed the seeded thread.");
77} catch (error) {
78 console.error(`Desktop launch check failed while ${step}.`);
79 NodeFS.mkdirSync(artifactsDir, { recursive: true });
80 console.error(error instanceof Error ? error.message : error);
81 const logs = NodePath.join(t3Home, "userdata/logs");
82 if (NodeFS.existsSync(logs)) {
83 NodeFS.cpSync(logs, NodePath.join(artifactsDir, "logs"), { recursive: true });
84 const serverLog = NodePath.join(logs, "server-child.log");
85 if (NodeFS.existsSync(serverLog)) {
86 console.error("\nLast backend output (server-child.log):");
87 console.error(NodeFS.readFileSync(serverLog, "utf8").split("\n").slice(-20).join("\n"));
88 }
89 }
90 process.exitCode = 1;
91} finally {
92 if (app) {
93 // A hung app never finishes closing; kill it so the job does not wait for its timeout.
94 // Playwright drops the process handle once the app closes, so read it first.
95 const child = app.process();
96 const closed = app.close().catch(() => {});
97 await Promise.race([closed, NodeTimersPromises.setTimeout(10_000, undefined, { ref: false })]);
98 child.kill("SIGKILL");
99 }
100 NodeFS.rmSync(home, { recursive: true, force: true, maxRetries: 5 });
101}