stably 4.12.21 → 4.12.22-rc.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/dist/index.mjs +1 -1
- package/dist/node_modules/playwright/lib/cli/client/session.js +11 -2
- package/dist/node_modules/playwright/lib/cli/daemon/daemon.js +38 -20
- package/dist/node_modules/playwright/lib/cli/daemon/program.js +14 -0
- package/dist/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +11 -1
- package/dist/node_modules/playwright/lib/mcp/browser/config.js +6 -3
- package/dist/node_modules/playwright/lib/mcp/browser/config.js.rej +11 -0
- package/dist/node_modules/playwright/lib/mcp/browser/sessionLog.js +34 -0
- package/dist/stably-plugin-cli/skills/bulk-test-handling/SKILL.md +6 -0
- package/dist/stably-plugin-cli/skills/stably-browser/SKILL.md +34 -0
- package/package.json +1 -1
|
@@ -230,8 +230,17 @@ to restart the browser session.`);
|
|
|
230
230
|
resolve();
|
|
231
231
|
});
|
|
232
232
|
child.on("close", (code) => {
|
|
233
|
-
if (!signalled)
|
|
234
|
-
|
|
233
|
+
if (!signalled) {
|
|
234
|
+
// Read the .err log — when the daemon crashes before outputting ### Error
|
|
235
|
+
// (e.g., SyntaxError, missing module), the real error is only in this file.
|
|
236
|
+
let message = `Daemon process exited with code ${code}`;
|
|
237
|
+
try {
|
|
238
|
+
const errLogContent = import_fs.default.readFileSync(errLog, "utf-8").trim();
|
|
239
|
+
if (errLogContent)
|
|
240
|
+
message += "\n" + errLogContent;
|
|
241
|
+
} catch {}
|
|
242
|
+
reject(new Error(message));
|
|
243
|
+
}
|
|
235
244
|
});
|
|
236
245
|
});
|
|
237
246
|
process.off("SIGINT", sigintHandler);
|
|
@@ -43,27 +43,9 @@ var import_socketConnection = require("../client/socketConnection");
|
|
|
43
43
|
var import_commands = require("./commands");
|
|
44
44
|
var import_command = require("./command");
|
|
45
45
|
const daemonDebug = (0, import_utilsBundle.debug)("pw:daemon");
|
|
46
|
-
async function socketExists(socketPath) {
|
|
47
|
-
try {
|
|
48
|
-
const stat = await import_promises.default.stat(socketPath);
|
|
49
|
-
if (stat?.isSocket())
|
|
50
|
-
return true;
|
|
51
|
-
} catch (e) {
|
|
52
|
-
}
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
46
|
async function startMcpDaemonServer(config, contextFactory) {
|
|
56
47
|
const sessionConfig = config.sessionConfig;
|
|
57
48
|
const { socketPath, version } = sessionConfig;
|
|
58
|
-
if (import_os.default.platform() !== "win32" && await socketExists(socketPath)) {
|
|
59
|
-
daemonDebug(`Socket already exists, removing: ${socketPath}`);
|
|
60
|
-
try {
|
|
61
|
-
await import_promises.default.unlink(socketPath);
|
|
62
|
-
} catch (error) {
|
|
63
|
-
daemonDebug(`Failed to remove existing socket: ${error}`);
|
|
64
|
-
throw error;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
49
|
const cwd = import_url.default.pathToFileURL(process.cwd()).href;
|
|
68
50
|
const clientInfo = {
|
|
69
51
|
name: "playwright-cli",
|
|
@@ -194,6 +176,9 @@ async function startMcpDaemonServer(config, contextFactory) {
|
|
|
194
176
|
const shutdown = (exitCode) => {
|
|
195
177
|
daemonDebug(`shutting down daemon with exit code ${exitCode}`);
|
|
196
178
|
server.close();
|
|
179
|
+
if (import_os.default.platform() !== "win32") {
|
|
180
|
+
try { require("fs").unlinkSync(socketPath); } catch {}
|
|
181
|
+
}
|
|
197
182
|
(0, import_utils.gracefullyProcessExitDoNotHang)(exitCode);
|
|
198
183
|
};
|
|
199
184
|
const server = import_net.default.createServer((socket) => {
|
|
@@ -230,9 +215,42 @@ async function startMcpDaemonServer(config, contextFactory) {
|
|
|
230
215
|
};
|
|
231
216
|
});
|
|
232
217
|
return new Promise((resolve, reject) => {
|
|
218
|
+
let retried = false;
|
|
233
219
|
server.on("error", (error) => {
|
|
234
|
-
|
|
235
|
-
|
|
220
|
+
if (error.code !== "EADDRINUSE" || retried) {
|
|
221
|
+
daemonDebug(`server error: ${error.message}`);
|
|
222
|
+
return reject(error);
|
|
223
|
+
}
|
|
224
|
+
daemonDebug("Socket already in use, checking if daemon is alive...");
|
|
225
|
+
const client = import_net.default.connect(socketPath);
|
|
226
|
+
client.setTimeout(5000);
|
|
227
|
+
client.on("timeout", () => {
|
|
228
|
+
client.destroy();
|
|
229
|
+
retried = true;
|
|
230
|
+
reject(new Error(
|
|
231
|
+
`Daemon at ${socketPath} is not responding (connect timed out). ` +
|
|
232
|
+
`Remove the socket manually or kill the process.`
|
|
233
|
+
));
|
|
234
|
+
});
|
|
235
|
+
client.on("connect", () => {
|
|
236
|
+
client.destroy();
|
|
237
|
+
reject(new Error(
|
|
238
|
+
`Another daemon is already running on ${socketPath}. ` +
|
|
239
|
+
`Close it with 'stably-browser close' first.`
|
|
240
|
+
));
|
|
241
|
+
});
|
|
242
|
+
client.on("error", () => {
|
|
243
|
+
if (retried) return;
|
|
244
|
+
daemonDebug("Stale socket detected, removing and retrying...");
|
|
245
|
+
if (import_os.default.platform() !== "win32") {
|
|
246
|
+
try { require("fs").unlinkSync(socketPath); } catch {}
|
|
247
|
+
}
|
|
248
|
+
retried = true;
|
|
249
|
+
server.listen(socketPath, () => {
|
|
250
|
+
daemonDebug(`daemon server listening on ${socketPath}`);
|
|
251
|
+
resolve(socketPath);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
236
254
|
});
|
|
237
255
|
server.listen(socketPath, () => {
|
|
238
256
|
daemonDebug(`daemon server listening on ${socketPath}`);
|
|
@@ -43,6 +43,20 @@ function decorateCLICommand(command, version) {
|
|
|
43
43
|
options.chromiumSandbox = options.chromiumSandbox === true ? void 0 : false;
|
|
44
44
|
(0, import_watchdog.setupExitWatchdog)();
|
|
45
45
|
const config = await resolveCLIConfig(options.daemonSession);
|
|
46
|
+
// Docker's /dev/shm defaults to 64MB. Chrome uses /dev/shm for renderer shared
|
|
47
|
+
// memory (frame compositing, screenshot buffers). Complex pages can push Chrome
|
|
48
|
+
// past 64MB, silently crashing the renderer and killing the CDP connection.
|
|
49
|
+
// --disable-dev-shm-usage tells Chrome to use /tmp instead.
|
|
50
|
+
//
|
|
51
|
+
// Injected AFTER resolveCLIConfig() so it operates on the final resolved config,
|
|
52
|
+
// not the pipeline inputs — doesn't interfere with user-set PLAYWRIGHT_MCP_CONFIG
|
|
53
|
+
// or any other config source (defaults, env vars, --config= files, CLI flags).
|
|
54
|
+
// Harmless on non-Docker: both /dev/shm and /tmp are tmpfs.
|
|
55
|
+
// Harmless for CDP connections: CdpContextFactory ignores launchOptions.args.
|
|
56
|
+
if (!config.browser.launchOptions.args)
|
|
57
|
+
config.browser.launchOptions.args = [];
|
|
58
|
+
if (!config.browser.launchOptions.args.includes("--disable-dev-shm-usage"))
|
|
59
|
+
config.browser.launchOptions.args.push("--disable-dev-shm-usage");
|
|
46
60
|
const browserContextFactory = (0, import_browserContextFactory.contextFactory)(config);
|
|
47
61
|
const extensionContextFactory = new import_extensionContextFactory.ExtensionContextFactory(config.browser.launchOptions.channel || "chrome", config.browser.userDataDir, config.browser.launchOptions.executablePath);
|
|
48
62
|
const cf = config.extension ? extensionContextFactory : browserContextFactory;
|
|
@@ -147,7 +147,17 @@ class CdpContextFactory extends BaseContextFactory {
|
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
149
|
async _doCreateContext(browser) {
|
|
150
|
-
|
|
150
|
+
if (this.config.browser.isolated)
|
|
151
|
+
return await browser.newContext();
|
|
152
|
+
// Non-isolated: reuse the default context so pages survive across sessions.
|
|
153
|
+
// After a browser crash/disconnect, _browserPromise is cleared (see BaseContextFactory)
|
|
154
|
+
// and _obtainBrowser reconnects via connectOverCDP. The fresh connection should have
|
|
155
|
+
// a default context, but if contexts()[0] is undefined (e.g., browser was restarted
|
|
156
|
+
// without a default context), fall back to creating a new one so the daemon doesn't crash.
|
|
157
|
+
const context = browser.contexts()[0];
|
|
158
|
+
if (context)
|
|
159
|
+
return context;
|
|
160
|
+
return await browser.newContext();
|
|
151
161
|
}
|
|
152
162
|
}
|
|
153
163
|
class RemoteContextFactory extends BaseContextFactory {
|
|
@@ -106,9 +106,12 @@ async function resolveCLIConfig(cliOptions) {
|
|
|
106
106
|
}
|
|
107
107
|
async function validateConfig(config) {
|
|
108
108
|
if (config.browser.browserName === "chromium" && config.browser.launchOptions.chromiumSandbox === void 0) {
|
|
109
|
-
if (process.platform === "linux")
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
if (process.platform === "linux") {
|
|
110
|
+
// On Linux, enable sandbox for non-bundled channels (chrome, msedge, etc.) UNLESS
|
|
111
|
+
// running as root. Chrome refuses to sandbox when running as root (common in CI/Docker).
|
|
112
|
+
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
113
|
+
config.browser.launchOptions.chromiumSandbox = isRoot ? false : config.browser.launchOptions.channel !== "chromium";
|
|
114
|
+
} else
|
|
112
115
|
config.browser.launchOptions.chromiumSandbox = true;
|
|
113
116
|
}
|
|
114
117
|
if (config.saveVideo && !checkFfmpeg()) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
--- lib/mcp/browser/config.js
|
|
2
|
+
+++ lib/mcp/browser/config.js
|
|
3
|
+
@@ -305,7 +305,7 @@
|
|
4
|
+
function outputDir(config, clientInfo) {
|
|
5
|
+
if (config.outputDir)
|
|
6
|
+
return import_path.default.resolve(config.outputDir);
|
|
7
|
+
const rootPath = (0, import_server2.firstRootPath)(clientInfo);
|
|
8
|
+
if (rootPath)
|
|
9
|
+
- return import_path.default.resolve(rootPath, config.skillMode ? ".playwright-cli" : ".playwright-mcp");
|
|
10
|
+
+ return import_path.default.resolve(rootPath, config.skillMode ? ".stably-browser" : ".playwright-mcp");
|
|
11
|
+
const tmpDir = process.env.PW_TMPDIR_FOR_TEST ?? import_os.default.tmpdir();
|
|
@@ -118,6 +118,40 @@ class SessionLog {
|
|
|
118
118
|
lines.push("");
|
|
119
119
|
this._sessionFileQueue = this._sessionFileQueue.then(() => import_fs.default.promises.appendFile(this._file, lines.join("\n")));
|
|
120
120
|
}
|
|
121
|
+
logUserAction(action, tab, code, isUpdate) {
|
|
122
|
+
code = code.trim();
|
|
123
|
+
// Send recorder event for user action tracking
|
|
124
|
+
const actionForLog = { ...action };
|
|
125
|
+
delete actionForLog.ariaSnapshot;
|
|
126
|
+
delete actionForLog.selector;
|
|
127
|
+
actionForLog.isUpdate = !!isUpdate;
|
|
128
|
+
sendRecorderEvent({
|
|
129
|
+
type: "user-action",
|
|
130
|
+
action: actionForLog,
|
|
131
|
+
code,
|
|
132
|
+
url: tab.page.url(),
|
|
133
|
+
timestamp: Date.now()
|
|
134
|
+
});
|
|
135
|
+
// Also log to session file
|
|
136
|
+
const lines = [""];
|
|
137
|
+
lines.push(
|
|
138
|
+
`### User action: ${action.name}`,
|
|
139
|
+
"- Args",
|
|
140
|
+
"```json",
|
|
141
|
+
JSON.stringify(actionForLog, null, 2),
|
|
142
|
+
"```"
|
|
143
|
+
);
|
|
144
|
+
if (code) {
|
|
145
|
+
lines.push(
|
|
146
|
+
"- Code",
|
|
147
|
+
"```js",
|
|
148
|
+
code,
|
|
149
|
+
"```"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
lines.push("");
|
|
153
|
+
this._sessionFileQueue = this._sessionFileQueue.then(() => import_fs.default.promises.appendFile(this._file, lines.join("\n")));
|
|
154
|
+
}
|
|
121
155
|
}
|
|
122
156
|
// Annotate the CommonJS export names for ESM import in node:
|
|
123
157
|
0 && (module.exports = {
|
|
@@ -101,6 +101,12 @@ After all batches complete, provide:
|
|
|
101
101
|
- List of any failed/skipped tests with reasons
|
|
102
102
|
- Suggestions for resolving issues
|
|
103
103
|
|
|
104
|
+
## Orchestrator Available
|
|
105
|
+
|
|
106
|
+
If `create-planner` and `create-worker` subagents are available (check your available subagent types), defer to the orchestrator for parallel execution instead of using this batch approach. The orchestrator groups tests by page and runs workers in parallel, which is faster than sequential batching. This skill applies only when the orchestrator subagents are not available.
|
|
107
|
+
|
|
108
|
+
Note: the batching guidance in this skill (browser-close between tests, batch sizes of ~6) still applies *within* each orchestrator worker — workers process their page group's tests sequentially with the same browser-reset pattern.
|
|
109
|
+
|
|
104
110
|
## Do NOT
|
|
105
111
|
|
|
106
112
|
- Start creating tests without informing user of scope
|
|
@@ -231,6 +231,40 @@ stably-browser run-test --help
|
|
|
231
231
|
- Overrides `browser`/`context`/`page` fixtures to connect via CDP (no new browser launched)
|
|
232
232
|
- Preserves browser state after the run (pages remain open for inspection)
|
|
233
233
|
|
|
234
|
+
## Test Creation Workflow
|
|
235
|
+
|
|
236
|
+
Use `stably-browser` as a live exploration tool during test creation, not just as a final verifier.
|
|
237
|
+
|
|
238
|
+
Preferred pattern when feasible:
|
|
239
|
+
|
|
240
|
+
1. Open the page and explore it live.
|
|
241
|
+
```bash
|
|
242
|
+
stably-browser open https://example.com/target
|
|
243
|
+
stably-browser click e15
|
|
244
|
+
stably-browser fill e22 "query"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
2. Read the snapshot returned by each action.
|
|
248
|
+
- Treat the browser snapshot as ground truth for visible DOM structure, roles, labels, text, and control behavior.
|
|
249
|
+
- Use code/source reads for routes, feature flags, auth/setup, and hidden preconditions.
|
|
250
|
+
|
|
251
|
+
3. Reuse emitted Playwright code.
|
|
252
|
+
- `stably-browser` actions emit concrete Playwright code/locator suggestions.
|
|
253
|
+
- Prefer copying that evidence into the test instead of inventing selectors from scratch.
|
|
254
|
+
|
|
255
|
+
4. Run `stably-browser run-test` to verify.
|
|
256
|
+
- Use `run-test` after you understand the page, or earlier when it is needed to bootstrap project-aware auth/setup.
|
|
257
|
+
- After `run-test`, prefer inspecting the persisted browser/session before blindly rerunning from the error text alone.
|
|
258
|
+
|
|
259
|
+
5. If auth/setup requires a project-aware bootstrap:
|
|
260
|
+
- Direct `open` may not reproduce Playwright project auth/setup on its own.
|
|
261
|
+
- In those repos, use `stably-browser run-test --project=<project>` or a seed test to establish the correct state, then inspect the persisted browser/session.
|
|
262
|
+
|
|
263
|
+
Anti-patterns:
|
|
264
|
+
- Guessing selectors from source code when the live browser already shows the actual DOM
|
|
265
|
+
- Treating `run-test` as the only discovery tool
|
|
266
|
+
- Blindly looping `run-test` → read error → edit without inspecting the browser state in between
|
|
267
|
+
|
|
234
268
|
### Running Multiple Tests — Sequential vs Parallel
|
|
235
269
|
|
|
236
270
|
When verifying that multiple independent tests pass, each test needs its own clean browser state. Two approaches:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stably",
|
|
3
|
-
"version": "4.12.
|
|
3
|
+
"version": "4.12.22-rc.0",
|
|
4
4
|
"packageManager": "pnpm@10.24.0",
|
|
5
5
|
"description": "AI-powered E2E Playwright testing CLI. Stably can understand your codebase, edit/run tests, and handle complex test scenarios for you.",
|
|
6
6
|
"main": "dist/index.mjs",
|