rechrome 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # rech — Remote Chrome
1
+ # rechrome — Remote Chrome
2
+
3
+ [![npm](https://img.shields.io/npm/v/rechrome)](https://www.npmjs.com/package/rechrome)
2
4
 
3
5
  CLI proxy for running [Playwright](https://playwright.dev/) commands on a shared remote browser. Run a server on a machine with a browser, then send commands from any client.
4
6
 
@@ -22,14 +24,17 @@ Built on top of [playwright-multi-tab](https://github.com/snomiao/playwright-mul
22
24
  ## Install
23
25
 
24
26
  ```bash
25
- # Clone and link globally
26
- git clone https://github.com/snomiao/rech.git
27
- cd rech
27
+ # From npm
28
+ bunx rechrome --help
29
+
30
+ # Or clone and link globally
31
+ git clone https://github.com/snomiao/rechrome.git
32
+ cd rechrome
28
33
  bun install
29
34
  bun link
30
35
  ```
31
36
 
32
- Now `rech` is available globally.
37
+ Now `rechrome` (or `rech`) is available globally.
33
38
 
34
39
  ## Quick start
35
40
 
@@ -38,7 +43,7 @@ Now `rech` is available globally.
38
43
  On the machine with a browser:
39
44
 
40
45
  ```bash
41
- rech serve
46
+ rechrome serve
42
47
  ```
43
48
 
44
49
  This auto-generates a connection URL in `.env.local` (with an auth key).
@@ -51,16 +56,16 @@ Copy the `REMOTE_CHROME_URL` from the server's `.env.local` to the client:
51
56
  export REMOTE_CHROME_URL=remote-chrome://YOUR_KEY@server-host:13775
52
57
 
53
58
  # Open a URL
54
- rech open https://example.com
59
+ rechrome open https://example.com
55
60
 
56
61
  # Take a screenshot
57
- rech screenshot
62
+ rechrome screenshot
58
63
 
59
64
  # List open tabs
60
- rech tab-list
65
+ rechrome tab-list
61
66
 
62
67
  # Any playwright-cli command works
63
- rech --help
68
+ rechrome --help
64
69
  ```
65
70
 
66
71
  ## Configuration
@@ -73,7 +78,7 @@ cp .env.example .env.local
73
78
 
74
79
  | Variable | Description | Default |
75
80
  |---|---|---|
76
- | `REMOTE_CHROME_URL` | Connection URL (auto-generated by `rech serve`) | — |
81
+ | `REMOTE_CHROME_URL` | Connection URL (auto-generated by `rechrome serve`) | — |
77
82
  | `GEMINI_API_KEY` | Gemini API key for screenshot vision descriptions | — |
78
83
  | `PLAYWRIGHT_CLI` | Path to playwright-cli binary (recommended: `playwright-cli-multi-tab` for full multi-tab support) | `playwright-cli` |
79
84
  | `RECH_HOST` | Server bind address | `127.0.0.1` |
@@ -103,7 +108,7 @@ bun test
103
108
 
104
109
  ## Related
105
110
 
106
- - [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — the underlying Playwright fork powering rech's multi-tab and multi-session browser control
111
+ - [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — the underlying Playwright fork powering rechrome's multi-tab and multi-session browser control
107
112
 
108
113
  ## License
109
114
 
package/package.json CHANGED
@@ -1,15 +1,35 @@
1
- {
1
+ {
2
2
  "name": "rechrome",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/snomiao/rechrome.git"
8
+ },
5
9
  "bin": {
6
- "rech": "./rech.ts"
10
+ "rech": "./rech.js",
11
+ "rechrome": "./rech.js"
7
12
  },
8
13
  "scripts": {
9
14
  "serve": "bun run rech.ts serve",
10
- "test": "bun test"
15
+ "test": "bun test",
16
+ "prepublishOnly": "sed 's/\\.ts/\\.js/g' rech.ts > rech.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js"
11
17
  },
12
18
  "devDependencies": {
13
19
  "@types/bun": "latest"
14
- }
20
+ },
21
+ "release": {
22
+ "branches": [
23
+ "main"
24
+ ]
25
+ },
26
+ "files": [
27
+ "rech.ts",
28
+ "rech.js",
29
+ "serve.ts",
30
+ "serve.js",
31
+ ".env.example",
32
+ "README.md",
33
+ "LICENSE"
34
+ ]
15
35
  }
package/rech.js ADDED
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { file } from "bun";
4
+ import { randomBytes } from "crypto";
5
+ import { mkdirSync, appendFileSync, existsSync } from "fs";
6
+ import { hostname } from "os";
7
+ import { join, basename } from "path";
8
+
9
+ export const ENV_KEY = "REMOTE_CHROME_URL";
10
+ export const DEFAULT_PORT = 13775;
11
+ export const RECH_DIR = join(import.meta.dir, ".rech");
12
+ export const LOG_DIR = join(RECH_DIR, "logs");
13
+
14
+ // Load .env.local from script's directory (works even when invoked from elsewhere)
15
+ const envFile = join(import.meta.dir, ".env.local");
16
+
17
+ /** Load .env.local into process.env. */
18
+ async function loadEnv() {
19
+ const envRaw = await file(envFile)
20
+ .text()
21
+ .catch(() => "");
22
+ for (const line of envRaw.split("\n")) {
23
+ const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
24
+ if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
25
+ }
26
+ }
27
+ await loadEnv();
28
+
29
+ // Watch .env.local for changes and hot-reload
30
+ import { watch } from "node:fs";
31
+ if (existsSync(envFile)) {
32
+ watch(envFile, async () => {
33
+ log(".env.local changed, reloading");
34
+ await loadEnv();
35
+ });
36
+ }
37
+
38
+ /** Describe an image using Gemini vision API. Returns description or null on failure. */
39
+ export async function describeImage(imagePath: string): Promise<string | null> {
40
+ const apiKey = process.env.GEMINI_API_KEY;
41
+ if (!apiKey) return null;
42
+ try {
43
+ const imageData = await file(imagePath).arrayBuffer();
44
+ const base64 = Buffer.from(imageData).toString("base64");
45
+ const mimeType = imagePath.endsWith(".png") ? "image/png" : "image/jpeg";
46
+ const res = await fetch(
47
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview:generateContent?key=${apiKey}`,
48
+ {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({
52
+ contents: [
53
+ {
54
+ parts: [
55
+ {
56
+ text: "Describe this browser screenshot concisely in 2-3 sentences. Focus on what's visible: page layout, content, any errors or issues.",
57
+ },
58
+ { inline_data: { mime_type: mimeType, data: base64 } },
59
+ ],
60
+ },
61
+ ],
62
+ }),
63
+ },
64
+ );
65
+ if (!res.ok) return null;
66
+ const json = await res.json();
67
+ return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export function log(msg: string) {
74
+ mkdirSync(LOG_DIR, { recursive: true });
75
+ const ts = new Date().toISOString();
76
+ const line = `[${ts}] ${msg}\n`;
77
+ console.error(line.trimEnd());
78
+ const logFile = join(LOG_DIR, `${ts.slice(0, 10)}.log`);
79
+ appendFileSync(logFile, line);
80
+ }
81
+
82
+ export function parseUrl(raw: string) {
83
+ const u = new URL(raw);
84
+ return { key: u.username, host: u.hostname, port: parseInt(u.port) || DEFAULT_PORT };
85
+ }
86
+
87
+ export async function getOrCreateUrl(): Promise<string> {
88
+ if (process.env[ENV_KEY]) return process.env[ENV_KEY];
89
+ const key = randomBytes(9).toString("base64url"); // 12 chars
90
+ const url = `remote-chrome://${key}@${hostname()}:${DEFAULT_PORT}`;
91
+ const newLine = `${ENV_KEY}=${url}`;
92
+ const envRaw = await file(envFile)
93
+ .text()
94
+ .catch(() => "");
95
+ const content = envRaw.trimEnd() ? envRaw.trimEnd() + "\n" + newLine + "\n" : newLine + "\n";
96
+ Bun.write(envFile, content);
97
+ process.env[ENV_KEY] = url;
98
+ return url;
99
+ }
100
+
101
+ export function authCheck(req: Request, key: string): Response | null {
102
+ const bearer = req.headers.get("authorization")?.replace("Bearer ", "");
103
+ if (bearer !== key) return new Response("Unauthorized", { status: 401 });
104
+ return null;
105
+ }
106
+
107
+ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string; cwd?: string }> {
108
+ const cwd = process.cwd();
109
+ try {
110
+ const remoteProc = Bun.spawn(["git", "remote", "get-url", "origin"], {
111
+ cwd,
112
+ stdout: "pipe",
113
+ stderr: "ignore",
114
+ });
115
+ const remoteUrl = (await new Response(remoteProc.stdout).text()).trim();
116
+ await remoteProc.exited;
117
+
118
+ const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
119
+ cwd,
120
+ stdout: "pipe",
121
+ stderr: "ignore",
122
+ });
123
+ const branch = (await new Response(branchProc.stdout).text()).trim();
124
+ await branchProc.exited;
125
+
126
+ if (remoteUrl) {
127
+ let gitUrl: string;
128
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
129
+ const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
130
+ if (sshMatch) {
131
+ gitUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
132
+ } else if (httpsMatch) {
133
+ gitUrl = `https://${httpsMatch[1]}/${httpsMatch[2]}`;
134
+ } else {
135
+ gitUrl = remoteUrl.replace(/\.git$/, "");
136
+ }
137
+ if (branch) gitUrl += `/tree/${branch}`;
138
+ // Strip any embedded credentials from the URL
139
+ try { const u = new URL(gitUrl); u.username = ""; u.password = ""; gitUrl = u.toString(); } catch {}
140
+ return { gitUrl };
141
+ }
142
+ } catch {}
143
+ return { hostname: hostname(), cwd };
144
+ }
145
+
146
+ async function run(url: string, args: string[]) {
147
+ const { key, host, port } = parseUrl(url);
148
+ const identity = await getClientIdentity();
149
+ console.error(
150
+ `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`})`,
151
+ );
152
+ const res = await fetch(`http://${host}:${port}/run`, {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
155
+ body: JSON.stringify({ args, identity }),
156
+ signal: AbortSignal.timeout(70_000),
157
+ }).catch((e) => {
158
+ console.error(`[rech] ${e.message}`);
159
+ process.exit(1);
160
+ });
161
+
162
+ if (res.status === 401) {
163
+ console.error("Unauthorized: bad key");
164
+ process.exit(1);
165
+ }
166
+
167
+ const { status, stdout, stderr, files, descriptions, existingSession } = (await res.json()) as {
168
+ status: number;
169
+ stdout: string;
170
+ stderr: string;
171
+ files?: string[];
172
+ descriptions?: Record<string, string>;
173
+ existingSession?: boolean;
174
+ };
175
+
176
+ if (existingSession) {
177
+ console.error(
178
+ `[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
179
+ );
180
+ }
181
+ if (stderr) process.stderr.write(stderr);
182
+ if (stdout) process.stdout.write(stdout);
183
+
184
+ if (files?.length) {
185
+ const dlDir = join(process.cwd(), ".playwright-cli-multi-tab");
186
+ mkdirSync(dlDir, { recursive: true });
187
+ const gitignorePath = join(dlDir, ".gitignore");
188
+ if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
189
+ for (const name of files) {
190
+ const fileRes = await fetch(`http://${host}:${port}/files/${name}`, {
191
+ headers: { Authorization: `Bearer ${key}` },
192
+ });
193
+ if (!fileRes.ok) continue;
194
+ const dest = join(dlDir, basename(name));
195
+ await Bun.write(dest, fileRes);
196
+ console.error(`[rech] downloaded: ${dest}`);
197
+ if (descriptions?.[name]) {
198
+ console.error(`[rech] vision: ${descriptions[name]}`);
199
+ }
200
+ }
201
+ }
202
+
203
+ process.exit(status);
204
+ }
205
+
206
+ if (import.meta.main) {
207
+ const args = process.argv.slice(2);
208
+
209
+ if (args[0] === "serve") {
210
+ const { serve } = await import("./serve.js");
211
+ serve();
212
+ } else {
213
+ const url = process.env[ENV_KEY];
214
+ if (!url) {
215
+ console.error(
216
+ `Usage:\n rech serve\n ${ENV_KEY}=remote-chrome://key@host:${DEFAULT_PORT} rech <playwright-args...>`,
217
+ );
218
+ process.exit(1);
219
+ }
220
+ run(url, args);
221
+ }
222
+ }
package/rech.ts CHANGED
@@ -6,6 +6,11 @@ import { mkdirSync, appendFileSync, existsSync } from "fs";
6
6
  import { hostname } from "os";
7
7
  import { join, basename } from "path";
8
8
 
9
+ export const ENV_KEY = "REMOTE_CHROME_URL";
10
+ export const DEFAULT_PORT = 13775;
11
+ export const RECH_DIR = join(import.meta.dir, ".rech");
12
+ export const LOG_DIR = join(RECH_DIR, "logs");
13
+
9
14
  // Load .env.local from script's directory (works even when invoked from elsewhere)
10
15
  const envFile = join(import.meta.dir, ".env.local");
11
16
 
@@ -23,15 +28,12 @@ await loadEnv();
23
28
 
24
29
  // Watch .env.local for changes and hot-reload
25
30
  import { watch } from "node:fs";
26
- watch(envFile, async () => {
27
- log(".env.local changed, reloading");
28
- await loadEnv();
29
- });
30
-
31
- export const ENV_KEY = "REMOTE_CHROME_URL";
32
- export const DEFAULT_PORT = 13775;
33
- export const RECH_DIR = join(import.meta.dir, ".rech");
34
- export const LOG_DIR = join(RECH_DIR, "logs");
31
+ if (existsSync(envFile)) {
32
+ watch(envFile, async () => {
33
+ log(".env.local changed, reloading");
34
+ await loadEnv();
35
+ });
36
+ }
35
37
 
36
38
  /** Describe an image using Gemini vision API. Returns description or null on failure. */
37
39
  export async function describeImage(imagePath: string): Promise<string | null> {
package/serve.js ADDED
@@ -0,0 +1,202 @@
1
+ import { file } from "bun";
2
+ import { createHash } from "crypto";
3
+ import { mkdirSync } from "fs";
4
+ import { join, resolve, relative, isAbsolute } from "path";
5
+ import { log, parseUrl, getOrCreateUrl, authCheck, describeImage, RECH_DIR } from "./rech.js";
6
+
7
+ export function isUnderDir(base: string, candidate: string): boolean {
8
+ const absBase = resolve(base) + "/";
9
+ const absCandidate = resolve(base, candidate);
10
+ return absCandidate.startsWith(absBase);
11
+ }
12
+
13
+ export async function serve() {
14
+ const url = await getOrCreateUrl();
15
+ const { key, port } = parseUrl(url);
16
+
17
+ const workDir = join(RECH_DIR, "output");
18
+ mkdirSync(workDir, { recursive: true });
19
+
20
+ const listenHost = process.env.RECH_HOST || "127.0.0.1";
21
+ const server = Bun.serve({
22
+ hostname: listenHost,
23
+ port,
24
+ async fetch(req) {
25
+ const reqUrl = new URL(req.url);
26
+
27
+ // Serve files from output dir
28
+ if (reqUrl.pathname.startsWith("/files/")) {
29
+ const denied = authCheck(req, key);
30
+ if (denied) return denied;
31
+ const name = decodeURIComponent(reqUrl.pathname.slice(7));
32
+ if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
33
+ const resolved = resolve(workDir, name);
34
+ const f = file(resolved);
35
+ if (!(await f.exists())) return new Response("Not found", { status: 404 });
36
+ return new Response(f);
37
+ }
38
+
39
+ if (reqUrl.pathname !== "/run") return new Response("rech server\n");
40
+ const denied = authCheck(req, key);
41
+ if (denied) return denied;
42
+
43
+ const body = await req.json();
44
+ let args: string[];
45
+ let sessionId: string;
46
+ let clientName = "";
47
+ if (Array.isArray(body)) {
48
+ args = body;
49
+ const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
50
+ sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
51
+ clientName = clientAddr;
52
+ log(`session from client IP: ${clientAddr} -> ${sessionId}`);
53
+ } else {
54
+ args = body.args;
55
+ const id = body.identity as
56
+ | { gitUrl?: string; hostname?: string; cwd?: string }
57
+ | undefined;
58
+ const raw = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
59
+ if (raw) {
60
+ sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
61
+ clientName = raw;
62
+ log(`session from identity: ${raw} -> ${sessionId}`);
63
+ } else {
64
+ const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
65
+ sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
66
+ clientName = clientAddr;
67
+ log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
68
+ }
69
+ }
70
+
71
+ let clientSession = "";
72
+ const filteredArgs = args.filter((a) => {
73
+ const m = a.match(/^-s=(.+)$/);
74
+ if (m) {
75
+ clientSession = m[1];
76
+ return false;
77
+ }
78
+ return true;
79
+ });
80
+ const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
81
+
82
+ const bin = process.env.PLAYWRIGHT_CLI || "playwright-cli";
83
+
84
+ if (filteredArgs.length === 0) {
85
+ filteredArgs.push("--help");
86
+ }
87
+
88
+ log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
89
+
90
+ // For open commands, check if this session already has tabs open
91
+ const isOpenCmd = filteredArgs[0] === "open";
92
+ if (isOpenCmd) {
93
+ try {
94
+ const listProc = Bun.spawn([bin, "tab-list", "--extension", `-s=${namespacedSession}`], {
95
+ cwd: workDir,
96
+ stdin: "ignore",
97
+ stdout: "pipe",
98
+ stderr: "pipe",
99
+ env: { PATH: process.env.PATH, HOME: process.env.HOME },
100
+ });
101
+ const [listStatus, listOut] = await Promise.all([
102
+ listProc.exited,
103
+ new Response(listProc.stdout).text(),
104
+ ]);
105
+ if (listStatus === 0 && listOut.trim()) {
106
+ log(`session ${namespacedSession} already has tabs, returning tab-list hint`);
107
+ return Response.json({
108
+ status: 0,
109
+ stdout: listOut,
110
+ stderr: `[rech] session "${namespacedSession}" already has open tabs:\n`,
111
+ files: [],
112
+ existingSession: true,
113
+ });
114
+ }
115
+ } catch (e) {
116
+ log(`tab-list check failed: ${e}`);
117
+ }
118
+ }
119
+
120
+ const childEnv: Record<string, string | undefined> = {
121
+ PATH: process.env.PATH,
122
+ HOME: process.env.HOME,
123
+ TMPDIR: process.env.TMPDIR,
124
+ DISPLAY: process.env.DISPLAY,
125
+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
126
+ ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
127
+ };
128
+ const proc = Bun.spawn([bin, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
129
+ cwd: workDir,
130
+ stdin: "ignore",
131
+ stdout: "pipe",
132
+ stderr: "pipe",
133
+ env: childEnv,
134
+ });
135
+
136
+ const TIMEOUT = 60_000;
137
+ const timeout = new Promise<never>((_, reject) =>
138
+ setTimeout(() => {
139
+ proc.kill();
140
+ reject(new Error("timeout"));
141
+ }, TIMEOUT),
142
+ );
143
+ const [status, stdout, stderr] = await Promise.race([
144
+ Promise.all([
145
+ proc.exited,
146
+ new Response(proc.stdout).text(),
147
+ new Response(proc.stderr).text(),
148
+ ]),
149
+ timeout.then(() => [1, "", ""] as [number, string, string]),
150
+ ]).catch(
151
+ () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
152
+ );
153
+
154
+ log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
155
+
156
+ // Detect files mentioned in output
157
+ const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
158
+ const mentionedFiles = [
159
+ ...new Set(
160
+ [...stdout.matchAll(filePattern), ...stderr.matchAll(filePattern)].map((m) => m[0]),
161
+ ),
162
+ ];
163
+ const outputFiles: string[] = [];
164
+ for (const f of mentionedFiles) {
165
+ if (!isUnderDir(workDir, f)) continue;
166
+ if (await file(join(workDir, f)).exists()) {
167
+ outputFiles.push(f);
168
+ } else {
169
+ const basename = f.split("/").pop()!;
170
+ for (const subdir of [".playwright-cli", ".rech-multi-tab"]) {
171
+ const subpath = join(subdir, basename);
172
+ if (await file(join(workDir, subpath)).exists()) {
173
+ outputFiles.push(subpath);
174
+ break;
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ // Auto-describe screenshot files with Gemini vision
181
+ const descriptions: Record<string, string> = {};
182
+ for (const f of outputFiles) {
183
+ if (/\.(?:png|jpe?g)$/i.test(f)) {
184
+ const desc = await describeImage(join(workDir, f));
185
+ if (desc) descriptions[f] = desc;
186
+ }
187
+ }
188
+
189
+ const rebrand = (s: string) => s.replaceAll("npx playwright-cli", "rech");
190
+ return Response.json({
191
+ status,
192
+ stdout: rebrand(stdout),
193
+ stderr: rebrand(stderr),
194
+ files: outputFiles,
195
+ descriptions,
196
+ });
197
+ },
198
+ });
199
+
200
+ log(`serving on http://${server.hostname}:${server.port}`);
201
+ log(`Connection URL set (use .env.local to view)`);
202
+ }
package/rech.spec.ts DELETED
@@ -1,78 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { parseUrl, authCheck, describeImage, DEFAULT_PORT, ENV_KEY } from "./rech.ts";
3
-
4
- describe("parseUrl", () => {
5
- test("parses key, host, and port from a remote-chrome URL", () => {
6
- const result = parseUrl("remote-chrome://mykey@example.com:9999");
7
- expect(result).toEqual({ key: "mykey", host: "example.com", port: 9999 });
8
- });
9
-
10
- test("falls back to DEFAULT_PORT when port is missing", () => {
11
- const result = parseUrl("remote-chrome://mykey@example.com");
12
- expect(result).toEqual({ key: "mykey", host: "example.com", port: DEFAULT_PORT });
13
- });
14
-
15
- test("handles URL-safe base64 characters in key", () => {
16
- const result = parseUrl("remote-chrome://ab_c-dEf12@host:8080");
17
- expect(result.key).toBe("ab_c-dEf12");
18
- });
19
-
20
- test("parses localhost URLs", () => {
21
- const result = parseUrl("remote-chrome://k@localhost:13775");
22
- expect(result).toEqual({ key: "k", host: "localhost", port: 13775 });
23
- });
24
- });
25
-
26
- describe("authCheck", () => {
27
- test("returns null for valid bearer token", () => {
28
- const req = new Request("http://localhost/run", {
29
- headers: { Authorization: "Bearer secret123" },
30
- });
31
- expect(authCheck(req, "secret123")).toBeNull();
32
- });
33
-
34
- test("returns 401 for wrong bearer token", () => {
35
- const req = new Request("http://localhost/run", {
36
- headers: { Authorization: "Bearer wrong" },
37
- });
38
- const res = authCheck(req, "secret123");
39
- expect(res).not.toBeNull();
40
- expect(res!.status).toBe(401);
41
- });
42
-
43
- test("returns 401 when no authorization header", () => {
44
- const req = new Request("http://localhost/run");
45
- const res = authCheck(req, "secret123");
46
- expect(res).not.toBeNull();
47
- expect(res!.status).toBe(401);
48
- });
49
-
50
- test("returns 401 for empty bearer token", () => {
51
- const req = new Request("http://localhost/run", {
52
- headers: { Authorization: "Bearer " },
53
- });
54
- const res = authCheck(req, "secret123");
55
- expect(res).not.toBeNull();
56
- expect(res!.status).toBe(401);
57
- });
58
- });
59
-
60
- describe("describeImage", () => {
61
- test("returns null when GEMINI_API_KEY is not set", async () => {
62
- const original = process.env.GEMINI_API_KEY;
63
- delete process.env.GEMINI_API_KEY;
64
- const result = await describeImage("/nonexistent/image.png");
65
- expect(result).toBeNull();
66
- if (original) process.env.GEMINI_API_KEY = original;
67
- });
68
- });
69
-
70
- describe("constants", () => {
71
- test("ENV_KEY is REMOTE_CHROME_URL", () => {
72
- expect(ENV_KEY).toBe("REMOTE_CHROME_URL");
73
- });
74
-
75
- test("DEFAULT_PORT is 13775", () => {
76
- expect(DEFAULT_PORT).toBe(13775);
77
- });
78
- });
package/serve.spec.ts DELETED
@@ -1,50 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { isUnderDir } from "./serve.ts";
3
-
4
- describe("isUnderDir", () => {
5
- test("allows simple relative file", () => {
6
- expect(isUnderDir("/app/output", "file.png")).toBe(true);
7
- });
8
-
9
- test("allows nested relative path", () => {
10
- expect(isUnderDir("/app/output", "subdir/file.png")).toBe(true);
11
- });
12
-
13
- test("blocks simple traversal with ../", () => {
14
- expect(isUnderDir("/app/output", "../secret.txt")).toBe(false);
15
- });
16
-
17
- test("blocks traversal that shares prefix (output-evil)", () => {
18
- expect(isUnderDir("/app/output", "../output-evil/secret.txt")).toBe(false);
19
- });
20
-
21
- test("blocks double traversal", () => {
22
- expect(isUnderDir("/app/output", "../../etc/passwd")).toBe(false);
23
- });
24
-
25
- test("blocks traversal hidden in middle of path", () => {
26
- expect(isUnderDir("/app/output", "subdir/../../etc/passwd")).toBe(false);
27
- });
28
-
29
- test("allows deeply nested path", () => {
30
- expect(isUnderDir("/app/output", "a/b/c/d/file.json")).toBe(true);
31
- });
32
-
33
- test("blocks absolute path outside base", () => {
34
- expect(isUnderDir("/app/output", "/etc/passwd")).toBe(false);
35
- });
36
-
37
- test("blocks dot-only path that resolves to base itself", () => {
38
- // "." resolves to base itself, not under it
39
- expect(isUnderDir("/app/output", ".")).toBe(false);
40
- });
41
-
42
- test("allows path starting with dot component", () => {
43
- expect(isUnderDir("/app/output", "./file.png")).toBe(true);
44
- });
45
-
46
- test("blocks percent-encoded traversal after decoding", () => {
47
- // The caller is responsible for decoding; test the resolved path
48
- expect(isUnderDir("/app/output", decodeURIComponent("..%2F..%2Fetc%2Fpasswd"))).toBe(false);
49
- });
50
- });