// Locating Playwright at runtime, for the modules that need a real browser // (the Nitter fetcher, the X fallback fetcher, the X session broker, and a // report's PDF export). // // Two constraints pull against each other: // - `common` must NOT depend on Playwright. It is imported by the Docker // build image, which installs no browsers, and by the client bundle path. // So there can be no static import and no entry in its package.json. // - The code still has to FIND Playwright when it runs, and in this pnpm // workspace it is installed only under `editor/` and `export/` — not // hoisted to the root and not visible from `common/`. A bare dynamic // import resolves relative to THIS file and therefore fails, which is // exactly how the Nitter fetcher first failed from the CLI. // // So: try the bare specifier first (works when the importing app owns it, e.g. // an editor server action), then resolve from the running process's directory, // then from the workspace packages that actually install it. import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { getPaths } from "../lib/paths"; // The slice of Playwright's chromium API these fetchers use. Structural, so no // Playwright types are needed at compile time. export type ChromiumLike = { launch: (opts: Record) => Promise; launchPersistentContext: ( dir: string, opts: Record, ) => Promise; }; export type BrowserLike = { newContext: (opts: Record) => Promise; close: () => Promise; }; export type PageLike = { goto: (url: string, opts?: unknown) => Promise; waitForSelector: (sel: string, opts?: unknown) => Promise; waitForTimeout: (ms: number) => Promise; evaluate: (fn: string) => Promise; on: (event: string, cb: (arg: never) => void) => void; // The post capture's shot (xPostCapture.ts): a PNG of the page, clipped to // the post. `clip` is in document coordinates when `fullPage` is set. screenshot: (opts?: { type?: "png" | "jpeg"; fullPage?: boolean; clip?: { x: number; y: number; width: number; height: number }; }) => Promise; // A report's PDF export (publish/reportExports.ts): the export's HTML set as // the page, printed. Optional: the fetchers' fakes do not print. setContent?: (html: string, opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle" }) => Promise; pdf?: (opts?: { format?: string; // A page size instead of a format (slides.pdf: 1280px × 720px). width?: string; height?: string; printBackground?: boolean; margin?: { top?: string; right?: string; bottom?: string; left?: string }; }) => Promise; // The page's own request context (the profile's cookies): an X Article's // inline images are fetched through it (xArticleCapture.ts). request?: { get: ( url: string, opts?: { timeout?: number; failOnStatusCode?: boolean }, ) => Promise<{ ok: () => boolean; status: () => number; headers: () => Record; body: () => Promise; }>; }; }; export type BrowserContextLike = { newPage: () => Promise; pages: () => PageLike[]; cookies: () => Promise; // The X fallback fetcher's browser-login path hands the operator's browser // cookies to a fresh context (xBrowserLogin.ts). addCookies: (cookies: ReadonlyArray>) => Promise; close: () => Promise; on: (event: string, cb: () => void) => void; }; // Assembled at runtime so TypeScript never tries to resolve it. const SPECIFIER = ["@playwright", "test"].join("/"); function candidateDirs(): string[] { const dirs = [process.cwd()]; try { const root = getPaths().monorepoRoot; // The workspace packages that actually declare @playwright/test. dirs.push(path.join(root, "editor"), path.join(root, "export"), root); } catch { /* getPaths can throw outside the repo — the cwd attempt still stands */ } return dirs; } // @playwright/test is CommonJS, so `await import()` of a resolved file path // yields `{ default: { chromium, ... } }` while a bare specifier may yield the // namespace directly. Accept either rather than silently handing back an // object whose `chromium` is undefined. function unwrap(mod: unknown): { chromium: ChromiumLike } | null { const m = mod as { chromium?: ChromiumLike; default?: { chromium?: ChromiumLike } }; const chromium = m?.chromium ?? m?.default?.chromium; return chromium ? { chromium } : null; } export async function importPlaywright(): Promise<{ chromium: ChromiumLike }> { const attempts: string[] = []; // 1. Plain specifier — succeeds when the importing app has it in scope. try { const hit = unwrap(await import(/* webpackIgnore: true */ SPECIFIER)); if (hit) return hit; attempts.push("bare: no chromium export"); } catch (err) { attempts.push(`bare: ${(err as Error).message.slice(0, 60)}`); } // 2. Resolve from the running process / workspace packages. for (const dir of candidateDirs()) { try { // createRequire needs a file path, not a directory. const req = createRequire(path.join(dir, "__resolve__.cjs")); const resolved = req.resolve(SPECIFIER); const hit = unwrap( await import(/* webpackIgnore: true */ pathToFileURL(resolved).href), ); if (hit) return hit; attempts.push(`${dir}: no chromium export`); } catch (err) { attempts.push(`${dir}: ${(err as Error).message.slice(0, 40)}`); } } throw new Error( "Playwright is not available on this host. It ships with the editor and " + "export packages; it is intentionally absent from `common` and from the " + "minimal Docker build image, so browser-backed fetching is an " + `editor-host concern. Tried — ${attempts.join(" | ")}`, ); }