Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,7 @@ export const runInit = async (options: InitOptions = {}) => {
`Installed! ${highlighter.info("expect-cli")} is now available globally.`,
);
} else {
globalSpinner.warn(
`Installed, but ${highlighter.info("expect-cli")} is not on your PATH.`,
);
globalSpinner.warn(`Installed, but ${highlighter.info("expect-cli")} is not on your PATH.`);
const globalPrefix = spawnSync("npm", ["prefix", "-g"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
Expand Down
11 changes: 8 additions & 3 deletions apps/cli/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ import { renderApp } from "./program";
import { CI_EXECUTION_TIMEOUT_MS, VERSION, VERSION_API_URL } from "./constants";
import { prompts } from "./utils/prompts";
import { highlighter } from "./utils/highlighter";
import { machineId } from "@expect/shared/machine-id";
import { logger } from "./utils/logger";

try {
fetch(`${VERSION_API_URL}?source=cli&t=${Date.now()}`).catch(() => {});
} catch {}
if (!isRunningInAgent() && !isHeadless()) {
machineId()
.catch(() => "unknown")
.then((mid) => {
fetch(`${VERSION_API_URL}?source=cli&mid=${mid}&t=${Date.now()}`).catch(() => {});
});
}

const DEFAULT_INSTRUCTION =
"Test all changes from main in the browser and verify they work correctly.";
Expand Down
4 changes: 1 addition & 3 deletions apps/cli/tests/add-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,7 @@ describe("extractTarEntries", () => {
});

it("creates nested directories", () => {
const tar = buildTar([
{ name: "prefix/sub/dir/file.txt", content: "nested" },
]);
const tar = buildTar([{ name: "prefix/sub/dir/file.txt", content: "nested" }]);

extractTarEntries(tar, "prefix/", destDir);

Expand Down
22 changes: 15 additions & 7 deletions apps/website/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { readFileSync } from "fs";
import * as fs from "fs";
import { NextResponse } from "next/server";
import { join } from "path";
import * as path from "path";

const skill = readFileSync(
join(process.cwd(), "..", "..", "packages", "expect-skill", "SKILL.md"),
"utf-8",
).replace(/^---[\s\S]*?---\n+/, "");
const root = path.join(process.cwd(), "..", "..");

const readme = fs
.readFileSync(path.join(root, "README.md"), "utf-8")
.replace(/^# <img[^>]*\/>\s*/m, "# ")
.replace(/\[!\[.*?\]\(.*?\)\]\(.*?\)\n?/g, "");

const skill = fs
.readFileSync(path.join(root, "packages", "expect-skill", "SKILL.md"), "utf-8")
.replace(/^---[\s\S]*?---\n+/, "");

const content = `${readme.trim()}\n\n---\n\n${skill.trim()}\n`;

export const GET = () =>
new NextResponse(skill, {
new NextResponse(content, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=86400",
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@
"effect": "4.0.0-beta.35",
"playwright": "^1.52.0",
"rrweb": "^2.0.0-alpha.18",
"which": "^6.0.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.15.0",
"@types/which": "^3.0.4",
"esbuild": "^0.25.0",
"typescript": "^5.7.0"
}
Expand Down
100 changes: 71 additions & 29 deletions packages/browser/src/browser.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Browsers, Cookies, layerLive, browserKeyOf, Cookie } from "@expect/cookies";
import type { Browser as BrowserProfile } from "@expect/cookies";
import { chromium, webkit, firefox } from "playwright";
import type { Locator, Page } from "playwright";
import type { Browser as PlaywrightBrowser, Locator, Page } from "playwright";
import type { BrowserEngine } from "./types";
import { Array as Arr, Effect, Layer, Option, ServiceMap } from "effect";

const cookiesLayer = Layer.mergeAll(layerLive, Cookies.layer);
import {
CDP_DISCOVERY_TIMEOUT_MS,
CONTENT_ROLES,
HEADLESS_CHROMIUM_ARGS,
INTERACTIVE_ROLES,
Expand All @@ -24,6 +25,7 @@ import {
NavigationError,
SnapshotTimeoutError,
} from "./errors";
import { autoDiscoverCdp } from "./cdp-discovery";
import { toActionError } from "./utils/action-error";
import { compactTree } from "./utils/compact-tree";
import { createLocator } from "./utils/create-locator";
Expand Down Expand Up @@ -194,32 +196,72 @@ export class Browser extends ServiceMap.Service<Browser>()("@browser/Browser", {
options: CreatePageOptions = {},
) {
const engine = options.browserType ?? "chromium";
const useLiveChrome = options.liveChrome && engine === "chromium" && !options.cdpUrl;
yield* Effect.annotateCurrentSpan({
url: url ?? "about:blank",
cdp: Boolean(options.cdpUrl),
liveChrome: Boolean(useLiveChrome),
browserType: engine,
});

const liveBrowser = yield* Effect.gen(function* () {
if (!useLiveChrome) return Option.none<PlaywrightBrowser>();

const discovered = yield* autoDiscoverCdp().pipe(
Effect.map(Option.some),
Effect.catchTag("CdpDiscoveryError", () => Effect.succeed(Option.none<string>())),
);

if (Option.isNone(discovered)) {
yield* Effect.logDebug("No running Chrome found, falling back to bundled Chromium");
return Option.none<PlaywrightBrowser>();
}

const connectedBrowser = yield* Effect.tryPromise(() =>
chromium.connectOverCDP(discovered.value, { timeout: CDP_DISCOVERY_TIMEOUT_MS }),
).pipe(
Effect.map(Option.some<PlaywrightBrowser>),
Effect.catchTag("UnknownError", () => Effect.succeed(Option.none<PlaywrightBrowser>())),
);

if (Option.isNone(connectedBrowser)) {
yield* Effect.logDebug(
"Failed to connect to discovered Chrome, falling back to bundled Chromium",
{
wsUrl: discovered.value,
},
);
return Option.none<PlaywrightBrowser>();
}

yield* Effect.logInfo("Connected to live Chrome", { wsUrl: discovered.value });
return connectedBrowser;
});

const isExternalBrowser = Option.isSome(liveBrowser);
const cdpEndpoint = engine === "chromium" ? options.cdpUrl : undefined;

const browserType = resolveBrowserType(engine);
const browser = cdpEndpoint
? yield* Effect.tryPromise({
try: () => chromium.connectOverCDP(cdpEndpoint),
catch: (cause) =>
new CdpConnectionError({
endpointUrl: cdpEndpoint,
cause: cause instanceof Error ? cause.message : String(cause),
}),
})
: yield* Effect.tryPromise({
try: () =>
browserType.launch({
headless: !options.headed,
executablePath: options.executablePath,
args: engine === "chromium" && !options.headed ? HEADLESS_CHROMIUM_ARGS : [],
}),
catch: toBrowserLaunchError,
});
const browser = Option.isSome(liveBrowser)
? liveBrowser.value
: cdpEndpoint
? yield* Effect.tryPromise({
try: () => chromium.connectOverCDP(cdpEndpoint),
catch: (cause) =>
new CdpConnectionError({
endpointUrl: cdpEndpoint,
cause: cause instanceof Error ? cause.message : String(cause),
}),
})
: yield* Effect.tryPromise({
try: () =>
browserType.launch({
headless: !options.headed,
executablePath: options.executablePath,
args: engine === "chromium" && !options.headed ? HEADLESS_CHROMIUM_ARGS : [],
}),
catch: toBrowserLaunchError,
});

const setupPage = Effect.gen(function* () {
const defaultBrowserContext =
Expand All @@ -243,7 +285,7 @@ export class Browser extends ServiceMap.Service<Browser>()("@browser/Browser", {
};
}

const isCdpConnected = Boolean(cdpEndpoint);
const isCdpConnected = isExternalBrowser || Boolean(cdpEndpoint);
const existingContexts = isCdpConnected ? browser.contexts() : [];
const context =
existingContexts.length > 0
Expand Down Expand Up @@ -282,13 +324,13 @@ export class Browser extends ServiceMap.Service<Browser>()("@browser/Browser", {
}

const existingPages = context.pages();
const page =
isCdpConnected && existingPages.length > 0
? existingPages[0]!
: yield* Effect.tryPromise({
try: () => context.newPage(),
catch: toBrowserLaunchError,
});
const reuseExistingPage = isCdpConnected && existingPages.length > 0 && !isExternalBrowser;
const page = reuseExistingPage
? existingPages[0]!
: yield* Effect.tryPromise({
try: () => context.newPage(),
catch: toBrowserLaunchError,
});

if (url) {
yield* Effect.tryPromise({
Expand All @@ -301,12 +343,12 @@ export class Browser extends ServiceMap.Service<Browser>()("@browser/Browser", {
});
}

return { browser, context, page };
return { browser, context, page, cleanup: Effect.void, isExternalBrowser };
});

return yield* setupPage.pipe(
Effect.tapError(() => {
if (cdpEndpoint) return Effect.void;
if (isExternalBrowser) return Effect.void;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Avoid closing externally supplied CDP sessions on setup errors. With options.cdpUrl, isExternalBrowser is false, so this cleanup now calls browser.close() and can shut down a user-provided Chrome instance.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/browser/src/browser.ts, line 351:

<comment>Avoid closing externally supplied CDP sessions on setup errors. With `options.cdpUrl`, `isExternalBrowser` is false, so this cleanup now calls `browser.close()` and can shut down a user-provided Chrome instance.</comment>

<file context>
@@ -322,20 +343,16 @@ export class Browser extends ServiceMap.Service<Browser>()("@browser/Browser", {
-          ),
-        ),
+        Effect.tapError(() => {
+          if (isExternalBrowser) return Effect.void;
+          return Effect.tryPromise(() => browser.close()).pipe(
+            Effect.catchTag("UnknownError", () => Effect.void),
</file context>
Suggested change
if (isExternalBrowser) return Effect.void;
if (isExternalBrowser || cdpEndpoint) return Effect.void;
Fix with Cubic

return Effect.tryPromise(() => browser.close()).pipe(
Effect.catchTag("UnknownError", () => Effect.void),
);
Expand Down
21 changes: 6 additions & 15 deletions packages/browser/src/cdp-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import net from "node:net";
import { Effect, Option } from "effect";
import { CDP_DISCOVERY_TIMEOUT_MS, CDP_COMMON_PORTS, CDP_PORT_PROBE_TIMEOUT_MS } from "./constants";
import { CdpDiscoveryError } from "./errors";
import { parseDevToolsActivePort } from "./utils/parse-devtools-active-port";

interface VersionInfo {
readonly webSocketDebuggerUrl?: string;
Expand Down Expand Up @@ -105,9 +106,6 @@ export const discoverCdpUrl = Effect.fn("discoverCdpUrl")(function* (host: strin
const listResult = yield* tryDiscover(discoverViaJsonList(host, port));
if (Option.isSome(listResult)) return listResult.value;

const reachable = yield* isPortReachable(host, port);
if (reachable) return `ws://${host}:${port}/devtools/browser`;

return yield* new CdpDiscoveryError({
cause: `All CDP discovery methods failed for ${host}:${port}`,
});
Expand All @@ -126,6 +124,7 @@ const getChromeUserDataDirs = () => {
path.join(base, "BraveSoftware", "Brave-Browser"),
path.join(base, "Microsoft Edge"),
path.join(base, "Arc", "User Data"),
path.join(base, "net.imput.helium"),
];
}

Expand Down Expand Up @@ -164,21 +163,13 @@ const readDevToolsActivePort = (userDataDir: string) =>
}),
}).pipe(
Effect.flatMap((content) => {
const lines = content.trim().split("\n");
const portStr = lines[0]?.trim();
if (!portStr) {
return new CdpDiscoveryError({
cause: `Empty DevToolsActivePort in ${userDataDir}`,
}).asEffect();
}
const port = Number.parseInt(portStr, 10);
if (Number.isNaN(port)) {
const parsed = parseDevToolsActivePort(content);
if (!parsed) {
return new CdpDiscoveryError({
cause: `Invalid port in DevToolsActivePort: ${portStr}`,
cause: `Invalid DevToolsActivePort in ${userDataDir}`,
}).asEffect();
}
const wsPath = lines[1]?.trim() ?? "/devtools/browser";
return Effect.succeed({ port, wsPath });
return Effect.succeed(parsed);
}),
);

Expand Down
Loading
Loading