@emseepea/testing 0.25.0 → 0.25.2
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 +70 -2
- package/chatgpt/run.mjs +28 -13
- package/chatgpt/session.mjs +83 -0
- package/chatgpt/setup.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -643,8 +643,9 @@ days.
|
|
|
643
643
|
|
|
644
644
|
Import `@emseepea/testing/chatgpt` only when you want to test the real ChatGPT
|
|
645
645
|
web experience. Ordinary framework tests do not enable this adapter or install
|
|
646
|
-
Playwright. This is an experimental browser
|
|
647
|
-
|
|
646
|
+
Playwright. This is an experimental browser adapter: ChatGPT controls can
|
|
647
|
+
change, so adopters must qualify each capability they rely on against their
|
|
648
|
+
owned account and endpoint.
|
|
648
649
|
|
|
649
650
|
On your own machine, install the optional browser tooling:
|
|
650
651
|
|
|
@@ -670,6 +671,63 @@ The command verifies a
|
|
|
670
671
|
signed-in control, not the account's identity; the human confirms that identity.
|
|
671
672
|
If that control changes, pass an account-specific `--authenticated-selector`.
|
|
672
673
|
|
|
674
|
+
### Use an already-authorized browser
|
|
675
|
+
|
|
676
|
+
If the launched browser repeats a security challenge, stop setup. Complete
|
|
677
|
+
login, MFA, CAPTCHA, and consent yourself in an ordinary browser. The setup
|
|
678
|
+
helper keeps Chromium's sandbox enabled, but cannot guarantee that an
|
|
679
|
+
automation-launched browser will be accepted by the login service.
|
|
680
|
+
|
|
681
|
+
An existing Playwright integration can pass its owner-authorized `Page` directly
|
|
682
|
+
to `runNativeChatGptJourney`. The adapter does not need to launch that browser.
|
|
683
|
+
It cannot attach to an arbitrary ordinary Chrome window without a supported
|
|
684
|
+
browser control interface.
|
|
685
|
+
|
|
686
|
+
Chrome versions with GUI remote debugging expose it at
|
|
687
|
+
`chrome://inspect/#remote-debugging`. The browser owner can select "Allow remote
|
|
688
|
+
debugging for this browser instance" and approve the connection in Chrome.
|
|
689
|
+
This needs no command-line browser launch.
|
|
690
|
+
|
|
691
|
+
The runner must still execute on
|
|
692
|
+
the browser's host; enabling this option does not give a cloud runner access
|
|
693
|
+
to another computer. Use a dedicated test browser and disable debugging when
|
|
694
|
+
finished.
|
|
695
|
+
|
|
696
|
+
For a Chromium session that the adopter has already opened and exposed through
|
|
697
|
+
Chrome DevTools Protocol (CDP), the execution command can attach locally:
|
|
698
|
+
|
|
699
|
+
```sh
|
|
700
|
+
npx emseepea-chatgpt --enable-native-chatgpt --scenario ./native-chatgpt.mjs \
|
|
701
|
+
--connect-cdp 'ws://127.0.0.1:9222/devtools/browser/<browser-id>' \
|
|
702
|
+
--browser-owner '<test-account-owner>'
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
This is an alternative to the setup command, not a way to solve a CAPTCHA.
|
|
706
|
+
The browser owner must confirm the signed-in account and complete connection
|
|
707
|
+
consent before attaching. `--browser-owner` must match the scenario's account
|
|
708
|
+
owner. Use a dedicated synthetic-test browser with one browser context.
|
|
709
|
+
|
|
710
|
+
The command accepts only a browser WebSocket on literal `127.0.0.1`, an explicit
|
|
711
|
+
port from 1024 to 65535, and `/devtools/browser` with an optional browser ID.
|
|
712
|
+
Remote hosts,
|
|
713
|
+
credentials, query strings, redirects, HTTP discovery, and profile options are
|
|
714
|
+
rejected.
|
|
715
|
+
|
|
716
|
+
WebSocket messages and the initial send queue are capped at 16 MiB.
|
|
717
|
+
Keep the debugging interface and address private, bound to loopback, and
|
|
718
|
+
available only while testing; CDP grants browser control and is not an account
|
|
719
|
+
authorization mechanism.
|
|
720
|
+
|
|
721
|
+
Attachment opens a separate test tab in the authorized context. It does not
|
|
722
|
+
read or export cookies or storage state, inspect existing tabs, launch Chrome,
|
|
723
|
+
or alter login behavior or the owner's browser defaults.
|
|
724
|
+
|
|
725
|
+
After the scenario's required cleanup, the runner
|
|
726
|
+
closes its tab and disconnects. The owner browser and its existing tabs remain
|
|
727
|
+
open. Missing browser access or incomplete authentication blocks verification.
|
|
728
|
+
|
|
729
|
+
### Connect and run the owned scenario
|
|
730
|
+
|
|
673
731
|
Connect your authorized HTTPS MCP endpoint through ChatGPT's current plugin
|
|
674
732
|
installation flow. Complete account consent yourself. A local server requires
|
|
675
733
|
a supported HTTPS tunnel before ChatGPT can reach it. See the
|
|
@@ -728,6 +786,16 @@ Return ordered, run-scoped calls matching the declared method, target, outcome,
|
|
|
728
786
|
and optional result hash. Each call includes request/result SHA-256 values,
|
|
729
787
|
endpoint SHA-256, sequence, timestamp, and `nativeClient`.
|
|
730
788
|
|
|
789
|
+
Keep deterministic MCP qualification and native-host evidence separate.
|
|
790
|
+
Command-line qualification can prove a resource link's shape, catalogue
|
|
791
|
+
membership, exact `resources/read` result, and original bytes. A native ChatGPT
|
|
792
|
+
journey separately proves whether ChatGPT materializes or rejects that link.
|
|
793
|
+
|
|
794
|
+
Declare the expected `tools/call` and `resources/read` as distinct server calls.
|
|
795
|
+
If ChatGPT does not issue the expected read, record the checkpoint as
|
|
796
|
+
`incomplete` with category `server-observation-missing`; do not treat the
|
|
797
|
+
tool-returned URI as proof of a native resource read.
|
|
798
|
+
|
|
731
799
|
Set
|
|
732
800
|
`nativeClient: "verified-chatgpt"` only when your authenticated server boundary
|
|
733
801
|
establishes that the request came through the dedicated ChatGPT connection;
|
package/chatgpt/run.mjs
CHANGED
|
@@ -4,12 +4,15 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { attachOwnedBrowser, validateBrowserAttachment } from "./session.mjs";
|
|
7
8
|
|
|
8
9
|
const help = `Usage: emseepea-chatgpt --enable-native-chatgpt --scenario <owned-module.mjs>
|
|
9
10
|
[--profile <dedicated-directory>] [--output <private-evidence.json>]
|
|
11
|
+
Or attach: --connect-cdp <loopback-browser-WebSocket> --browser-owner <owner>
|
|
10
12
|
|
|
11
13
|
The module exports authorization and createJourney({ page, runId }). It owns
|
|
12
|
-
its assertions, server observations, and cleanup. Complete human setup first
|
|
14
|
+
its assertions, server observations, and cleanup. Complete human setup first,
|
|
15
|
+
or explicitly confirm ownership of an already-authorized local CDP browser.
|
|
13
16
|
This command never logs in, grants consent, or exports authentication state.
|
|
14
17
|
`;
|
|
15
18
|
|
|
@@ -21,37 +24,49 @@ async function main() {
|
|
|
21
24
|
for (let index = 0; index < args.length; index += 1) {
|
|
22
25
|
const arg = args[index];
|
|
23
26
|
if (arg === "--enable-native-chatgpt") continue;
|
|
24
|
-
if (!["--scenario", "--profile", "--output"].includes(arg) || values.has(arg) || !args[index + 1] || args[index + 1].startsWith("--")) throw new Error("invalid-run-options");
|
|
27
|
+
if (!["--scenario", "--profile", "--output", "--connect-cdp", "--browser-owner"].includes(arg) || values.has(arg) || !args[index + 1] || args[index + 1].startsWith("--")) throw new Error("invalid-run-options");
|
|
25
28
|
values.set(arg, args[++index]);
|
|
26
29
|
}
|
|
27
30
|
if (!values.get("--scenario")) throw new Error("owned-scenario-required");
|
|
28
31
|
const scenario = await import(pathToFileURL(resolve(values.get("--scenario"))).href);
|
|
29
32
|
if (typeof scenario.createJourney !== "function" || scenario.authorization?.approved !== true || typeof scenario.authorization.accountOwner !== "string") throw new Error("owned-scenario-required");
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const attached = values.has("--connect-cdp");
|
|
34
|
+
if ((attached && values.has("--profile")) || (!attached && values.has("--browser-owner"))) throw new Error("invalid-run-options");
|
|
35
|
+
if (attached) validateBrowserAttachment(values.get("--connect-cdp"), values.get("--browser-owner"), scenario.authorization.accountOwner);
|
|
36
|
+
let profile;
|
|
37
|
+
let record;
|
|
38
|
+
if (!attached) {
|
|
39
|
+
profile = resolve(values.get("--profile") ?? join(homedir(), ".emseepea", "chatgpt-browser"));
|
|
40
|
+
record = await readFile(join(profile, "emseepea-profile.json"), "utf8").then(JSON.parse).catch(() => undefined);
|
|
41
|
+
const ownerSha256 = createHash("sha256").update(scenario.authorization.accountOwner).digest("hex");
|
|
42
|
+
if (record?.schemaVersion !== 1 || record?.humanConfirmed !== true || record?.ownerSha256 !== ownerSha256 || !["chromium", "chrome", "msedge"].includes(record?.channel)) throw new Error("human-setup-required");
|
|
43
|
+
}
|
|
34
44
|
let chromium;
|
|
35
45
|
try { ({ chromium } = await import("playwright")); }
|
|
36
46
|
catch { throw new Error("optional-playwright-dependency-required"); }
|
|
37
47
|
const { runNativeChatGptJourney } = await import("../dist/chatgpt.js");
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
let session;
|
|
49
|
+
if (attached) session = await attachOwnedBrowser({ chromium, endpoint: values.get("--connect-cdp"), browserOwner: values.get("--browser-owner"), accountOwner: scenario.authorization.accountOwner });
|
|
50
|
+
else {
|
|
51
|
+
let context;
|
|
52
|
+
try { context = await chromium.launchPersistentContext(profile, { headless: false, chromiumSandbox: true, ...(record.channel === "chromium" ? {} : { channel: record.channel }) }); }
|
|
53
|
+
catch { throw new Error("headed-browser-unavailable"); }
|
|
54
|
+
session = { page: context.pages()[0] ?? await context.newPage(), close: () => context.close() };
|
|
55
|
+
}
|
|
41
56
|
try {
|
|
42
|
-
const page =
|
|
43
|
-
await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded" });
|
|
57
|
+
const page = session.page;
|
|
58
|
+
if (!attached) await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded" });
|
|
44
59
|
const runId = randomUUID();
|
|
45
60
|
const declared = await scenario.createJourney({ page, runId });
|
|
46
61
|
const evidence = await runNativeChatGptJourney({ ...declared, enabled: true, page, runId, authorization: scenario.authorization },
|
|
47
62
|
resolve(values.get("--output") ?? "artifacts/native-chatgpt/evidence.json"));
|
|
48
63
|
console.log(`Native ChatGPT journey ${evidence.status}. Evidence written privately.`);
|
|
49
64
|
if (evidence.status !== "passed") process.exitCode = evidence.status === "failed" ? 1 : 2;
|
|
50
|
-
} finally { await
|
|
65
|
+
} finally { await session.close(); }
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
main().catch((error) => {
|
|
54
|
-
const categories = new Set(["explicit-opt-in-required", "invalid-run-options", "owned-scenario-required", "human-setup-required", "optional-playwright-dependency-required", "headed-browser-unavailable"]);
|
|
69
|
+
const categories = new Set(["explicit-opt-in-required", "invalid-run-options", "owned-scenario-required", "human-setup-required", "optional-playwright-dependency-required", "headed-browser-unavailable", "browser-owner-confirmation-required", "loopback-browser-endpoint-required", "owned-browser-attachment-unavailable"]);
|
|
55
70
|
console.error(`Native ChatGPT journey blocked: ${categories.has(error.message) ? error.message : "journey-unavailable"}`);
|
|
56
71
|
process.exitCode = 2;
|
|
57
72
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The owner exposes this local browser interface; this module never launches,
|
|
2
|
+
// authenticates, exports storage, or bypasses a browser challenge.
|
|
3
|
+
export function validateBrowserAttachment(endpoint, browserOwner, accountOwner) {
|
|
4
|
+
if (typeof accountOwner !== "string" || !accountOwner.trim() || browserOwner !== accountOwner) throw new Error("browser-owner-confirmation-required");
|
|
5
|
+
if (typeof endpoint !== "string" || endpoint.length > 256 || !/^ws:\/\/127\.0\.0\.1:[0-9]+\/devtools\/browser(?:\/[A-Za-z0-9-]+)?$/.test(endpoint)) throw new Error("loopback-browser-endpoint-required");
|
|
6
|
+
let url;
|
|
7
|
+
try { url = new URL(endpoint); }
|
|
8
|
+
catch { throw new Error("loopback-browser-endpoint-required"); }
|
|
9
|
+
if (Number(url.port) < 1024 || Number(url.port) > 65535 || !url.port || url.pathname.length > 150) throw new Error("loopback-browser-endpoint-required");
|
|
10
|
+
return url.href;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function attachOwnedBrowser({ chromium, endpoint, browserOwner, accountOwner }) {
|
|
14
|
+
const address = validateBrowserAttachment(endpoint, browserOwner, accountOwner);
|
|
15
|
+
const transport = browserTransport(address);
|
|
16
|
+
let browser;
|
|
17
|
+
let page;
|
|
18
|
+
try {
|
|
19
|
+
// Supported Playwright transport seam: Node's WebSocket rejects redirects,
|
|
20
|
+
// while Playwright's URL connector follows them. Keep browser defaults.
|
|
21
|
+
transport.open();
|
|
22
|
+
browser = await chromium.connectOverCDP(transport, { timeout: 30_000, noDefaults: true });
|
|
23
|
+
const contexts = browser.contexts();
|
|
24
|
+
if (contexts.length !== 1) throw new Error("owned-browser-context-required");
|
|
25
|
+
// A new tab shares the owner's authorized session without selecting,
|
|
26
|
+
// navigating, closing, or retaining any pre-existing private tab.
|
|
27
|
+
page = await contexts[0].newPage();
|
|
28
|
+
await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
29
|
+
return { page, async close() {
|
|
30
|
+
try { await page.close({ runBeforeUnload: false }); }
|
|
31
|
+
finally { await browser.close(); } // CDP-connected close disconnects, not browser termination.
|
|
32
|
+
} };
|
|
33
|
+
} catch {
|
|
34
|
+
try { await page?.close({ runBeforeUnload: false }); } catch { /* redacted */ }
|
|
35
|
+
try { await browser?.close(); } catch { /* redacted */ }
|
|
36
|
+
transport.close();
|
|
37
|
+
throw new Error("owned-browser-attachment-unavailable");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only JSON framing for Playwright's public ConnectOverCDPTransport interface.
|
|
42
|
+
// Playwright owns all CDP/browser semantics. No messages are retained or logged.
|
|
43
|
+
function browserTransport(address) {
|
|
44
|
+
let socket;
|
|
45
|
+
let ended = false;
|
|
46
|
+
const pending = [];
|
|
47
|
+
let pendingBytes = 0;
|
|
48
|
+
const finish = () => {
|
|
49
|
+
if (ended) return;
|
|
50
|
+
ended = true;
|
|
51
|
+
pending.length = 0;
|
|
52
|
+
pendingBytes = 0;
|
|
53
|
+
transport.onclose?.("browser-connection-closed");
|
|
54
|
+
};
|
|
55
|
+
const transport = {
|
|
56
|
+
open() {
|
|
57
|
+
socket = new WebSocket(address);
|
|
58
|
+
socket.onopen = () => {
|
|
59
|
+
try { for (const message of pending) socket.send(message); pending.length = 0; pendingBytes = 0; }
|
|
60
|
+
catch { transport.close(); finish(); }
|
|
61
|
+
};
|
|
62
|
+
socket.onmessage = (event) => {
|
|
63
|
+
try {
|
|
64
|
+
if (typeof event.data !== "string" || Buffer.byteLength(event.data) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
|
|
65
|
+
transport.onmessage?.(JSON.parse(event.data));
|
|
66
|
+
} catch { transport.close(); finish(); }
|
|
67
|
+
};
|
|
68
|
+
socket.onerror = finish;
|
|
69
|
+
socket.onclose = finish;
|
|
70
|
+
},
|
|
71
|
+
send(message) {
|
|
72
|
+
const encoded = JSON.stringify(message);
|
|
73
|
+
if (ended || Buffer.byteLength(encoded) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
|
|
74
|
+
if (socket.readyState === WebSocket.CONNECTING) {
|
|
75
|
+
if (pending.length >= 64 || pendingBytes + Buffer.byteLength(encoded) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
|
|
76
|
+
pending.push(encoded);
|
|
77
|
+
pendingBytes += Buffer.byteLength(encoded);
|
|
78
|
+
} else socket.send(encoded);
|
|
79
|
+
},
|
|
80
|
+
close() { socket?.close(); },
|
|
81
|
+
};
|
|
82
|
+
return transport;
|
|
83
|
+
}
|
package/chatgpt/setup.mjs
CHANGED
|
@@ -49,7 +49,7 @@ async function main() {
|
|
|
49
49
|
}
|
|
50
50
|
await chmod(profile, 0o700);
|
|
51
51
|
let context;
|
|
52
|
-
try { context = await chromium.launchPersistentContext(profile, { headless: false, ...(channel === "chromium" ? {} : { channel }) }); }
|
|
52
|
+
try { context = await chromium.launchPersistentContext(profile, { headless: false, chromiumSandbox: true, ...(channel === "chromium" ? {} : { channel }) }); }
|
|
53
53
|
catch { throw new Error("headed-browser-unavailable"); }
|
|
54
54
|
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
55
55
|
try {
|