| 1 | import type { KeyboardInputEvent, MouseInputEvent, MouseWheelInputEvent, Rectangle, WebPreferences } from "electron"; |
| 2 | import type { GuestDebugger, GuestFrame, GuestImage, GuestPage, GuestView, GuestViewEvents, GuestViewFactory } from "./guestView.js"; |
| 3 | |
| 4 | // In-memory stand-ins for WebContentsView so the browser modules run under |
| 5 | // plain node --test. Scripts are answered by the injected `run` callback. |
| 6 | |
| 7 | export type ScriptRunner = (code: string, frame: FakeFrame) => unknown; |
| 8 | |
| 9 | export class FakeFrame implements GuestFrame { |
| 10 | detached = false; |
| 11 | parent: FakeFrame | null = null; |
| 12 | children: FakeFrame[] = []; |
| 13 | |
| 14 | constructor(readonly frameTreeNodeId: number, public url: string, private readonly run: ScriptRunner) {} |
| 15 | |
| 16 | get framesInSubtree(): GuestFrame[] { |
| 17 | return [this, ...this.children.flatMap((child) => child.framesInSubtree)]; |
| 18 | } |
| 19 | |
| 20 | async executeJavaScript(code: string): Promise<unknown> { |
| 21 | return this.run(code, this); |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | export class FakeDebugger implements GuestDebugger { |
| 26 | attached = false; |
| 27 | readonly commands: Array<{ method: string; params: unknown }> = []; |
| 28 | private readonly listeners = new Set<(event: unknown, method: string, params: unknown) => void>(); |
| 29 | respond: (method: string, params: unknown) => unknown = () => ({}); |
| 30 | |
| 31 | isAttached(): boolean { |
| 32 | return this.attached; |
| 33 | } |
| 34 | |
| 35 | attach(): void { |
| 36 | this.attached = true; |
| 37 | } |
| 38 | |
| 39 | detach(): void { |
| 40 | this.attached = false; |
| 41 | } |
| 42 | |
| 43 | async sendCommand(method: string, params?: unknown): Promise<unknown> { |
| 44 | this.commands.push({ method, params }); |
| 45 | return this.respond(method, params); |
| 46 | } |
| 47 | |
| 48 | emit(method: string, params: unknown): void { |
| 49 | for (const listener of [...this.listeners]) listener({}, method, params); |
| 50 | } |
| 51 | |
| 52 | on(_event: "message", listener: (event: unknown, method: string, params: unknown) => void): void { |
| 53 | this.listeners.add(listener); |
| 54 | } |
| 55 | |
| 56 | removeListener(_event: "message", listener: (event: unknown, method: string, params: unknown) => void): void { |
| 57 | this.listeners.delete(listener); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | export class FakePage implements GuestPage { |
| 62 | url = "about:blank"; |
| 63 | title = ""; |
| 64 | loading = false; |
| 65 | destroyed = false; |
| 66 | zoom = 1; |
| 67 | devtools = false; |
| 68 | back = false; |
| 69 | forward = false; |
| 70 | readonly calls: string[] = []; |
| 71 | readonly inputs: Array<MouseInputEvent | MouseWheelInputEvent | KeyboardInputEvent> = []; |
| 72 | readonly inserted: string[] = []; |
| 73 | readonly mainFrame: FakeFrame; |
| 74 | readonly debugger = new FakeDebugger(); |
| 75 | loadResult: Promise<void> = Promise.resolve(); |
| 76 | image: GuestImage = { toPNG: () => Buffer.from("png"), getSize: () => ({ width: 8, height: 6 }) }; |
| 77 | readonly capturedRects: Array<Rectangle | undefined> = []; |
| 78 | |
| 79 | constructor(readonly id: number, public run: ScriptRunner = () => undefined) { |
| 80 | this.mainFrame = new FakeFrame(id * 100, this.url, (code, frame) => this.run(code, frame)); |
| 81 | } |
| 82 | |
| 83 | readonly navigationHistory = { |
| 84 | canGoBack: () => this.back, |
| 85 | canGoForward: () => this.forward, |
| 86 | goBack: () => this.calls.push("back"), |
| 87 | goForward: () => this.calls.push("forward"), |
| 88 | }; |
| 89 | |
| 90 | isDestroyed(): boolean { |
| 91 | return this.destroyed; |
| 92 | } |
| 93 | |
| 94 | loadURL(url: string): Promise<void> { |
| 95 | this.calls.push(`load:${url}`); |
| 96 | this.url = url; |
| 97 | return this.loadResult; |
| 98 | } |
| 99 | |
| 100 | getURL(): string { |
| 101 | return this.url; |
| 102 | } |
| 103 | |
| 104 | getTitle(): string { |
| 105 | return this.title; |
| 106 | } |
| 107 | |
| 108 | isLoading(): boolean { |
| 109 | return this.loading; |
| 110 | } |
| 111 | |
| 112 | reload(): void { |
| 113 | this.calls.push("reload"); |
| 114 | } |
| 115 | |
| 116 | stop(): void { |
| 117 | this.calls.push("stop"); |
| 118 | } |
| 119 | |
| 120 | getZoomFactor(): number { |
| 121 | return this.zoom; |
| 122 | } |
| 123 | |
| 124 | setZoomFactor(factor: number): void { |
| 125 | this.zoom = factor; |
| 126 | } |
| 127 | |
| 128 | isDevToolsOpened(): boolean { |
| 129 | return this.devtools; |
| 130 | } |
| 131 | |
| 132 | openDevTools(): void { |
| 133 | this.devtools = true; |
| 134 | } |
| 135 | |
| 136 | closeDevTools(): void { |
| 137 | this.devtools = false; |
| 138 | } |
| 139 | |
| 140 | focus(): void { |
| 141 | this.calls.push("focus"); |
| 142 | } |
| 143 | |
| 144 | async executeJavaScriptInIsolatedWorld(_worldId: number, scripts: { code: string }[]): Promise<unknown> { |
| 145 | return this.run(scripts[0]?.code ?? "", this.mainFrame); |
| 146 | } |
| 147 | |
| 148 | sendInputEvent(event: MouseInputEvent | MouseWheelInputEvent | KeyboardInputEvent): void { |
| 149 | this.inputs.push(event); |
| 150 | } |
| 151 | |
| 152 | async insertText(text: string): Promise<void> { |
| 153 | this.inserted.push(text); |
| 154 | } |
| 155 | |
| 156 | async capturePage(rect?: Rectangle): Promise<GuestImage> { |
| 157 | this.capturedRects.push(rect); |
| 158 | return this.image; |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | export class FakeGuestView implements GuestView { |
| 163 | readonly page: FakePage; |
| 164 | events: GuestViewEvents | null = null; |
| 165 | bounds: Rectangle | null = null; |
| 166 | visible = false; |
| 167 | destroyed = false; |
| 168 | |
| 169 | constructor(id: number, readonly partition: string, readonly inherited?: WebPreferences) { |
| 170 | this.page = new FakePage(id); |
| 171 | } |
| 172 | |
| 173 | bind(events: GuestViewEvents): void { |
| 174 | this.events = events; |
| 175 | } |
| 176 | |
| 177 | setBounds(bounds: Rectangle): void { |
| 178 | this.bounds = bounds; |
| 179 | } |
| 180 | |
| 181 | setVisible(visible: boolean): void { |
| 182 | this.visible = visible; |
| 183 | } |
| 184 | |
| 185 | destroy(): void { |
| 186 | this.destroyed = true; |
| 187 | this.page.destroyed = true; |
| 188 | } |
| 189 | |
| 190 | fire(): GuestViewEvents { |
| 191 | if (!this.events) throw new Error("view is not bound"); |
| 192 | return this.events; |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | export class FakeViewFactory implements GuestViewFactory { |
| 197 | readonly views: FakeGuestView[] = []; |
| 198 | private nextId = 1; |
| 199 | |
| 200 | create(partition: string, inherited?: WebPreferences): GuestView { |
| 201 | const view = new FakeGuestView(this.nextId++, partition, inherited); |
| 202 | this.views.push(view); |
| 203 | return view; |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | export const silentLog = { info() {}, warn() {}, error() {} }; |
| 208 |