|
| 1 | +import { Page, test as base, Response as PlaywrightResponse } from "@playwright/test"; |
| 2 | +import { join, relative } from "node:path"; |
| 3 | +import { fork, execSync } from "node:child_process"; |
| 4 | +import * as z from "zod"; |
| 5 | +import strip from "strip-ansi"; |
| 6 | + |
| 7 | +const messageSchema = z.discriminatedUnion("kind", [ |
| 8 | + z.object({ |
| 9 | + kind: z.literal("ready"), |
| 10 | + urls: z.object({ |
| 11 | + local: z.string(), |
| 12 | + ui: z.string() |
| 13 | + }), |
| 14 | + cwd: z.string() |
| 15 | + }) |
| 16 | +]); |
| 17 | +type Msg = z.infer<typeof messageSchema>; |
| 18 | + |
| 19 | +interface NextArgs { |
| 20 | + stdout: { lines: { count: number; after: number } }; |
| 21 | +} |
| 22 | + |
| 23 | +export const test = base.extend<{ |
| 24 | + bs: { |
| 25 | + url: string; |
| 26 | + cwd: string; |
| 27 | + uiUrl: string; |
| 28 | + child: any; |
| 29 | + stdout: string[]; |
| 30 | + touch: (path: string) => void; |
| 31 | + next: (args: NextArgs) => Promise<string[]>; |
| 32 | + }; |
| 33 | +}>({ |
| 34 | + bs: async ({}, use, testInfo) => { |
| 35 | + const last2 = testInfo.file.split("/").slice(-3, -1); |
| 36 | + const cwd = process.cwd(); |
| 37 | + const base = join(cwd, ...last2); |
| 38 | + const file = join(base, "run.js"); |
| 39 | + const stdout: string[] = []; |
| 40 | + |
| 41 | + // console.log("running", { |
| 42 | + // cwd, |
| 43 | + // base: relative(cwd, base), |
| 44 | + // file: relative(cwd, file), |
| 45 | + // testInfoFile: relative(cwd, testInfo.file) |
| 46 | + // }); |
| 47 | + |
| 48 | + const child = fork(file, { |
| 49 | + cwd: base, |
| 50 | + stdio: "pipe" |
| 51 | + }); |
| 52 | + |
| 53 | + child.stdout.on("data", d => { |
| 54 | + stdout.push(d.toString()); |
| 55 | + // console.log(d.toString()); |
| 56 | + }); |
| 57 | + child.stderr.on("data", d => console.error(d.toString())); |
| 58 | + |
| 59 | + const msg = await new Promise<Msg>((res, rej) => { |
| 60 | + child.on("spawn", (...args) => { |
| 61 | + // console.log("✅ spawned"); |
| 62 | + }); |
| 63 | + child.on("error", error => { |
| 64 | + rej(new Error("child process error" + error)); |
| 65 | + }); |
| 66 | + child.on("message", message => { |
| 67 | + // console.log("📩 message", message); |
| 68 | + const parsed = messageSchema.safeParse(message); |
| 69 | + if (parsed.success) { |
| 70 | + res(parsed.data); |
| 71 | + } else { |
| 72 | + // @ts-expect-error - not sure why this is not working |
| 73 | + rej(new Error("zod parsing error" + parsed.error)); |
| 74 | + } |
| 75 | + }); |
| 76 | + }); |
| 77 | + |
| 78 | + // console.log("---", msg.cwd); |
| 79 | + |
| 80 | + const closed = new Promise<any>((res, rej) => { |
| 81 | + child.on("exit", code => { |
| 82 | + // console.log("exit, code: ", code); |
| 83 | + }); |
| 84 | + child.on("close", code => { |
| 85 | + // console.log("[child]: close", code); |
| 86 | + res(code); |
| 87 | + }); |
| 88 | + child.on("disconnect", (...args) => { |
| 89 | + // console.log("disconnect", ...args); |
| 90 | + }); |
| 91 | + }); |
| 92 | + |
| 93 | + await use({ |
| 94 | + url: msg.urls.local, |
| 95 | + uiUrl: msg.urls.ui, |
| 96 | + cwd: msg.cwd, |
| 97 | + child, |
| 98 | + stdout, |
| 99 | + touch: (path: string) => { |
| 100 | + touchFile(join(msg.cwd, path)); |
| 101 | + }, |
| 102 | + next: async (args: NextArgs) => { |
| 103 | + const { |
| 104 | + stdout: { lines } |
| 105 | + } = args; |
| 106 | + return new Promise<string[]>((res, rej) => { |
| 107 | + const timeout = setTimeout(() => { |
| 108 | + clearTimeout(timeout); |
| 109 | + clearInterval(int); |
| 110 | + rej(new Error("timeout")); |
| 111 | + }, 2000); |
| 112 | + const int = setInterval(() => { |
| 113 | + if (stdout.length > lines.after) { |
| 114 | + const dif = stdout.length - lines.after; |
| 115 | + if (dif >= lines.count) { |
| 116 | + const next = stdout.slice(lines.after).map(line => { |
| 117 | + return strip(line); |
| 118 | + }); |
| 119 | + res(next); |
| 120 | + clearInterval(int); |
| 121 | + } |
| 122 | + } |
| 123 | + }, 100); |
| 124 | + }); |
| 125 | + } |
| 126 | + }); |
| 127 | + |
| 128 | + child.kill("SIGTERM"); |
| 129 | + |
| 130 | + await closed; |
| 131 | + } |
| 132 | +}); |
| 133 | + |
| 134 | +interface RequestForArgs { |
| 135 | + matches: |
| 136 | + | { |
| 137 | + url: (url: URL) => boolean; |
| 138 | + } |
| 139 | + | { |
| 140 | + pathname: string; |
| 141 | + }; |
| 142 | + when?: { |
| 143 | + pageLoads: { |
| 144 | + url: string; |
| 145 | + }; |
| 146 | + }; |
| 147 | +} |
| 148 | + |
| 149 | +export function makesRequestIn(page: Page, args: RequestForArgs) { |
| 150 | + const { matches, when } = args; |
| 151 | + |
| 152 | + if ("pathname" in matches) { |
| 153 | + const requestPromise = new Promise(res => { |
| 154 | + page.on("request", r => { |
| 155 | + const url = new URL(r.url()); |
| 156 | + if (url.pathname === matches.pathname) { |
| 157 | + res(url); |
| 158 | + } |
| 159 | + }); |
| 160 | + }); |
| 161 | + const jobs = [requestPromise]; |
| 162 | + if (when) { |
| 163 | + jobs.push(page.goto(when.pageLoads.url)); |
| 164 | + } |
| 165 | + const good = Promise.all(jobs); |
| 166 | + return Promise.race([ |
| 167 | + good, |
| 168 | + timeout({ msg: "waiting for request timed out: " + matches.pathname }) |
| 169 | + ]); |
| 170 | + } |
| 171 | + |
| 172 | + if ("url" in matches) { |
| 173 | + const requestPromise = new Promise(res => { |
| 174 | + page.on("request", r => { |
| 175 | + const url = new URL(r.url()); |
| 176 | + if (matches.url(url)) { |
| 177 | + res(url); |
| 178 | + } |
| 179 | + }); |
| 180 | + }); |
| 181 | + |
| 182 | + return Promise.race([requestPromise, timeout({ msg: "waiting for request timed out" })]); |
| 183 | + } |
| 184 | +} |
| 185 | + |
| 186 | +interface ResponseForArgs { |
| 187 | + pathname: string; |
| 188 | + when: { page: Page; loads: { url: string } }; |
| 189 | +} |
| 190 | + |
| 191 | +export async function responseFor(args: ResponseForArgs) { |
| 192 | + const { when, pathname } = args; |
| 193 | + const responseBody = new Promise<Buffer>(res => { |
| 194 | + when.page.on("response", r => { |
| 195 | + const url = new URL(r.url()); |
| 196 | + if (url.pathname === pathname) { |
| 197 | + res(r.body()); |
| 198 | + } |
| 199 | + }); |
| 200 | + }).then(x => x.toString()); |
| 201 | + |
| 202 | + const load = when.page.goto(when.loads.url); |
| 203 | + const good = Promise.all([responseBody, load]); |
| 204 | + return Promise.race([good, timeout({ msg: "waiting for response timed out: " + pathname })]); |
| 205 | +} |
| 206 | + |
| 207 | +function timeout({ ms, msg }: { ms?: number; msg?: string } = {}): Promise<never> { |
| 208 | + return new Promise((res, rej) => |
| 209 | + setTimeout(() => rej(new Error(msg || "timedout")), ms ?? 2000) |
| 210 | + ); |
| 211 | +} |
| 212 | + |
| 213 | +function touchFile(filePath) { |
| 214 | + execSync(`touch ${filePath}`); |
| 215 | +} |
0 commit comments