1import { expect } from "alchemy-test";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Semaphore from "effect/Semaphore";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import { initialCwd } from "@/Util/Node.ts";
8import * as Stream from "effect/Stream";
9import * as ChildProcess from "effect/process/ChildProcess";
10
11const command = Effect.fn(
12 function* (args: string[]) {
13 const child = yield* ChildProcess.make("terminal-browser", args, {
14 env: { ...process.env, NODE_OPTIONS: undefined },
15 });
16 const [code, stdout, stderr] = yield* Effect.all(
17 [
18 child.exitCode,
19 child.stdout.pipe(Stream.decodeText, Stream.mkString),
20 child.stderr.pipe(Stream.decodeText, Stream.mkString),
21 ],
22 { concurrency: "unbounded" },
23 );
24 if (code !== 0)
25 return yield* Effect.fail(new Error(`Browser command failed: ${stderr}`));
26 return stdout;
27 },
28 Effect.scoped,
29 Effect.timeout("10 seconds"),
30);
31
32const browsers = Semaphore.makeUnsafe(1);
33
34/** Opt-in real terminal-browser interactions against each deployed or native app. */
35export const browserRoundtrip = Effect.fn(
36 function* (url: string, slug: string) {
37 if (process.env.NEON_WEBSITE_BROWSER !== "1") return;
38 const fs = yield* FileSystem.FileSystem;
39 const path = yield* Path.Path;
40 const screenshots = path.join(
41 initialCwd,
42 ".alchemy",
43 "log",
44 "website-browser",
45 );
46 yield* fs.makeDirectory(screenshots, { recursive: true });
47 const opened = yield* command([
48 "new-tab",
49 slug === "vocs" ? `${url.replace(/\/+$/, "")}/counter` : url,
50 ]);
51 const browser = yield* Effect.try(
52 () =>
53 JSON.parse(opened) as {
54 key: string;
55 openedTab?: number;
56 tabs?: { id: number; active: boolean }[];
57 },
58 );
59 const openedTab =
60 browser.openedTab ?? browser.tabs?.find((tab) => tab.active)?.id;
61 if (openedTab === undefined)
62 return yield* Effect.fail(
63 new Error("Browser did not report its active tab"),
64 );
65 const tab = { key: browser.key, openedTab };
66 const action = (...args: string[]) =>
67 command([
68 "action",
69 "--browser",
70 tab.key,
71 "--tab",
72 String(tab.openedTab),
73 "--",
74 ...args,
75 ]);
76 yield* Effect.addFinalizer(() =>
77 action("eval", "location.href = 'about:blank'").pipe(
78 Effect.andThen(action("close")),
79 Effect.andThen(
80 command([
81 "action",
82 "--browser",
83 tab.key,
84 "--tab",
85 String(tab.openedTab),
86 "done",
87 ]),
88 ),
89 Effect.ignore,
90 ),
91 );
92 for (const [width, height] of [
93 [1280, 900],
94 [390, 844],
95 ]) {
96 yield* action("set", "viewport", String(width), String(height));
97 yield* action("reload");
98 const label = slug === "foldkit" ? "+" : "count: 0";
99 const snapshot = yield* action("snapshot", "-i").pipe(
100 Effect.repeat({
101 schedule: Schedule.spaced("500 millis"),
102 times: 8,
103 until: (text) => text.includes(label),
104 }),
105 );
106 expect(snapshot).toContain(label);
107 yield* action(
108 "find",
109 "role",
110 "button",
111 "click",
112 "--name",
113 label,
114 "--exact",
115 );
116 if (slug === "foldkit") {
117 expect(yield* action("get", "text", "#count")).toContain("1");
118 yield* action(
119 "find",
120 "role",
121 "button",
122 "click",
123 "--name",
124 "Reset",
125 "--exact",
126 );
127 expect(yield* action("get", "text", "#count")).toContain("0");
128 } else {
129 expect(yield* action("snapshot", "-i")).toContain("count: 1");
130 if (!["vocs", "waku"].includes(slug)) {
131 expect(snapshot).toContain("Load greeting");
132 yield* action(
133 "find",
134 "role",
135 "button",
136 "click",
137 "--name",
138 "Load greeting",
139 "--exact",
140 );
141 expect(
142 yield* action(
143 "get",
144 "text",
145 slug === "nextjs" ? 'section [role="status"]' : '[role="status"]',
146 ).pipe(
147 Effect.repeat({
148 schedule: Schedule.spaced("500 millis"),
149 times: 8,
150 until: (text) => text.includes("Hello from"),
151 }),
152 ),
153 ).toContain("Hello from");
154 }
155 }
156 if (slug === "nextjs") {
157 yield* action("fill", "#name", `Neon visitor ${width}`);
158 yield* action(
159 "find",
160 "role",
161 "button",
162 "click",
163 "--name",
164 "Submit name",
165 "--exact",
166 );
167 expect(
168 yield* action("get", "text", "body").pipe(
169 Effect.repeat({
170 schedule: Schedule.spaced("500 millis"),
171 times: 8,
172 until: (text) =>
173 text.includes(`Submitted: Neon visitor ${width}`),
174 }),
175 ),
176 ).toContain(`Submitted: Neon visitor ${width}`);
177 yield* action("reload");
178 expect(yield* action("get", "text", "body")).toContain(
179 `Submitted: Neon visitor ${width}`,
180 );
181 }
182 expect(
183 yield* action(
184 "eval",
185 "document.documentElement.scrollWidth <= window.innerWidth",
186 ),
187 ).toContain("true");
188 yield* action(
189 "screenshot",
190 path.join(
191 screenshots,
192 `${slug}-${url.startsWith("https:") ? "live" : "local"}-${width}.png`,
193 ),
194 );
195 if (["astro", "nuxt", "sveltekit", "waku"].includes(slug)) {
196 const route = slug === "astro" ? "/about/" : "/about";
197 yield* action("click", `a[href="${route}"]`);
198 expect(yield* action("get", "url")).toContain("/about");
199 yield* action("reload");
200 expect(yield* action("get", "text", "body")).toContain("prerendered");
201 yield* action("back");
202 }
203 if (slug === "vocs") {
204 const base = url.replace(/\/+$/, "");
205 for (const [route, heading] of [
206 ["/guide", "Deployment guide"],
207 ["/", "Alchemy with Vocs"],
208 ]) {
209 yield* action(
210 "click",
211 `nav[aria-label="Pagination"] a[href="${route}"]`,
212 );
213 const headingText = action("get", "text", "h1").pipe(
214 Effect.repeat({
215 schedule: Schedule.spaced("500 millis"),
216 until: (text) => text.includes(heading),
217 }),
218 Effect.timeout("10 seconds"),
219 );
220 expect(yield* headingText).toContain(heading);
221 yield* action("reload");
222 expect(yield* headingText).toContain(heading);
223 }
224 yield* action("back");
225 expect(yield* action("get", "url")).toContain("/guide");
226 yield* action(
227 "eval",
228 `location.href = ${JSON.stringify(`${base}/counter`)}`,
229 );
230 }
231 yield* Effect.logInfo(
232 `Website browser ${slug} ${width}x${height}: passed`,
233 );
234 }
235 },
236 Effect.scoped,
237 browsers.withPermit,
238);