@stablyai/playwright-base 0.1.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/ai/extract.d.ts +15 -0
- package/dist/ai/extract.js +85 -0
- package/dist/ai/verify-prompt.d.ts +7 -0
- package/dist/ai/verify-prompt.js +38 -0
- package/dist/expect.d.ts +13 -0
- package/dist/expect.js +84 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +15 -0
- package/dist/playwright-augment/augment.d.ts +9 -0
- package/dist/playwright-augment/augment.js +131 -0
- package/dist/playwright-augment/methods/agent.d.ts +9 -0
- package/dist/playwright-augment/methods/agent.js +12 -0
- package/dist/playwright-augment/methods/extract.d.ts +14 -0
- package/dist/playwright-augment/methods/extract.js +21 -0
- package/dist/playwright-type-predicates.d.ts +3 -0
- package/dist/playwright-type-predicates.js +16 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.js +19 -0
- package/package.json +22 -0
- package/src/ai/extract.ts +97 -0
- package/src/ai/verify-prompt.ts +57 -0
- package/src/expect.ts +134 -0
- package/src/index.ts +69 -0
- package/src/playwright-augment/augment.ts +207 -0
- package/src/playwright-augment/methods/agent.ts +19 -0
- package/src/playwright-augment/methods/extract.ts +48 -0
- package/src/playwright-type-predicates.ts +19 -0
- package/src/runtime.ts +19 -0
- package/tsconfig.build.json +6 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
2
|
+
import * as z4 from 'zod/v4/core';
|
|
3
|
+
export type ExtractSchema = z4.$ZodType;
|
|
4
|
+
export type SchemaOutput<T extends ExtractSchema> = z4.output<T>;
|
|
5
|
+
type ExtractSubject = Page | Locator;
|
|
6
|
+
type BaseExtractArgs = {
|
|
7
|
+
prompt: string;
|
|
8
|
+
pageOrLocator: ExtractSubject;
|
|
9
|
+
};
|
|
10
|
+
type ExtractArgsWithSchema<T extends ExtractSchema> = BaseExtractArgs & {
|
|
11
|
+
schema: T;
|
|
12
|
+
};
|
|
13
|
+
export declare function extract(args: BaseExtractArgs): Promise<string>;
|
|
14
|
+
export declare function extract<T extends ExtractSchema>(args: ExtractArgsWithSchema<T>): Promise<SchemaOutput<T>>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.extract = extract;
|
|
37
|
+
const z4 = __importStar(require("zod/v4/core"));
|
|
38
|
+
const zod_1 = require("zod");
|
|
39
|
+
const runtime_1 = require("../runtime");
|
|
40
|
+
const EXTRACT_ENDPOINT = 'https://api.stably.ai/internal/v1/extract';
|
|
41
|
+
const zSuccess = zod_1.z.object({ value: zod_1.z.unknown() });
|
|
42
|
+
const zError = zod_1.z.object({ error: zod_1.z.string() });
|
|
43
|
+
class ExtractValidationError extends Error {
|
|
44
|
+
constructor(message, issues) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.issues = issues;
|
|
47
|
+
this.name = 'ExtractValidationError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function validateWithSchema(schema, value) {
|
|
51
|
+
const result = await z4.safeParseAsync(schema, value);
|
|
52
|
+
if (!result.success) {
|
|
53
|
+
throw new ExtractValidationError('Validation failed', result.error.issues);
|
|
54
|
+
}
|
|
55
|
+
return result.data;
|
|
56
|
+
}
|
|
57
|
+
async function extract({ prompt, pageOrLocator, schema, }) {
|
|
58
|
+
const jsonSchema = schema ? z4.toJSONSchema(schema) : undefined;
|
|
59
|
+
const apiKey = (0, runtime_1.requireApiKey)();
|
|
60
|
+
const form = new FormData();
|
|
61
|
+
form.append('prompt', prompt);
|
|
62
|
+
if (jsonSchema) {
|
|
63
|
+
form.append('jsonSchema', JSON.stringify(jsonSchema));
|
|
64
|
+
}
|
|
65
|
+
const pngBuffer = await pageOrLocator.screenshot({ type: 'png' }); // Buffer
|
|
66
|
+
const u8 = Uint8Array.from(pngBuffer); // strips Buffer type → plain Uint8Array
|
|
67
|
+
const blob = new Blob([u8], { type: 'image/png' });
|
|
68
|
+
form.append('image', blob, 'screenshot.png');
|
|
69
|
+
const response = await fetch(EXTRACT_ENDPOINT, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `Bearer ${apiKey}`,
|
|
73
|
+
},
|
|
74
|
+
body: form,
|
|
75
|
+
});
|
|
76
|
+
const parsed = await response.json().catch(() => undefined);
|
|
77
|
+
if (response.ok) {
|
|
78
|
+
const { value } = zSuccess.parse(parsed);
|
|
79
|
+
return (schema ? await validateWithSchema(schema, value)
|
|
80
|
+
: typeof value === 'string' ? value
|
|
81
|
+
: JSON.stringify(value));
|
|
82
|
+
}
|
|
83
|
+
const err = zError.safeParse(parsed);
|
|
84
|
+
throw new Error(`Extract failed (${response.status})${err.success ? `: ${err.data.error}` : ''}`);
|
|
85
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifyPrompt = verifyPrompt;
|
|
4
|
+
const PROMPT_ASSERTION_ENDPOINT = 'https://api.stably.ai/internal/v1/assert';
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const runtime_1 = require("../runtime");
|
|
7
|
+
const zSuccess = zod_1.z.object({
|
|
8
|
+
success: zod_1.z.boolean(),
|
|
9
|
+
reason: zod_1.z.string().optional(),
|
|
10
|
+
});
|
|
11
|
+
const zError = zod_1.z.object({
|
|
12
|
+
error: zod_1.z.string(),
|
|
13
|
+
});
|
|
14
|
+
async function verifyPrompt({ prompt, screenshot, }) {
|
|
15
|
+
const apiKey = (0, runtime_1.requireApiKey)();
|
|
16
|
+
const form = new FormData();
|
|
17
|
+
form.append('prompt', prompt);
|
|
18
|
+
const u8 = Uint8Array.from(screenshot);
|
|
19
|
+
const blob = new Blob([u8], { type: 'image/png' });
|
|
20
|
+
form.append('image', blob, 'screenshot.png');
|
|
21
|
+
const response = await fetch(PROMPT_ASSERTION_ENDPOINT, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${apiKey}`,
|
|
25
|
+
},
|
|
26
|
+
body: form,
|
|
27
|
+
});
|
|
28
|
+
const parsed = await response.json().catch(() => undefined);
|
|
29
|
+
if (response.ok) {
|
|
30
|
+
const { success, reason } = zSuccess.parse(parsed);
|
|
31
|
+
return {
|
|
32
|
+
pass: success,
|
|
33
|
+
reason,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const err = zError.safeParse(parsed);
|
|
37
|
+
throw new Error(`Verify prompt failed (${response.status})${err.success ? `: ${err.data.error}` : ''}`);
|
|
38
|
+
}
|
package/dist/expect.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
2
|
+
import type { ScreenshotPromptOptions } from './index';
|
|
3
|
+
type MatcherContext = {
|
|
4
|
+
isNot: boolean;
|
|
5
|
+
message?: () => string;
|
|
6
|
+
};
|
|
7
|
+
export declare const stablyPlaywrightMatchers: {
|
|
8
|
+
readonly toMatchScreenshotPrompt: (this: MatcherContext, received: Page | Locator, condition: string, options?: ScreenshotPromptOptions) => Promise<{
|
|
9
|
+
pass: boolean;
|
|
10
|
+
message: () => string;
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
13
|
+
export {};
|
package/dist/expect.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stablyPlaywrightMatchers = void 0;
|
|
4
|
+
const playwright_type_predicates_1 = require("./playwright-type-predicates");
|
|
5
|
+
const verify_prompt_1 = require("./ai/verify-prompt");
|
|
6
|
+
function createFailureMessage({ targetType, condition, didPass, isNot, reason, }) {
|
|
7
|
+
const expectation = isNot ? 'not to satisfy' : 'to satisfy';
|
|
8
|
+
const result = didPass ? 'it did' : 'it did not';
|
|
9
|
+
let message = `Expected ${targetType} ${expectation} ${JSON.stringify(condition)}, but ${result}.`;
|
|
10
|
+
if (reason) {
|
|
11
|
+
message += `\n\nReason: ${reason}`;
|
|
12
|
+
}
|
|
13
|
+
return message;
|
|
14
|
+
}
|
|
15
|
+
function areScreenshotsEqual(a, b) {
|
|
16
|
+
if (a.byteLength !== b.byteLength) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
for (let index = 0; index < a.byteLength; index += 1) {
|
|
20
|
+
if (a[index] !== b[index]) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
async function takeStableScreenshot(target, options) {
|
|
27
|
+
var _a;
|
|
28
|
+
const page = (0, playwright_type_predicates_1.isPage)(target) ? target : target.page();
|
|
29
|
+
// Use a small budget for stabilization within the overall assertion timeout.
|
|
30
|
+
// We allocate up to 25% of the total timeout (bounded between 300ms and 2000ms).
|
|
31
|
+
const totalTimeout = (_a = options === null || options === void 0 ? void 0 : options.timeout) !== null && _a !== void 0 ? _a : 5000;
|
|
32
|
+
// Budget is 25% of the total timeout
|
|
33
|
+
const stabilizationBudgetMs = Math.floor(totalTimeout * 0.25);
|
|
34
|
+
const stabilityBudgetMs = Math.min(2000, Math.max(300, stabilizationBudgetMs));
|
|
35
|
+
const deadline = Date.now() + stabilityBudgetMs;
|
|
36
|
+
let actual;
|
|
37
|
+
let previous;
|
|
38
|
+
const pollIntervals = [0, 100, 250, 500];
|
|
39
|
+
let isFirstIteration = true;
|
|
40
|
+
while (true) {
|
|
41
|
+
if (Date.now() >= deadline)
|
|
42
|
+
break;
|
|
43
|
+
const delay = pollIntervals.length ? pollIntervals.shift() : 1000;
|
|
44
|
+
if (delay) {
|
|
45
|
+
await page.waitForTimeout(delay);
|
|
46
|
+
}
|
|
47
|
+
previous = actual;
|
|
48
|
+
const rawScreenshot = await target.screenshot(options);
|
|
49
|
+
actual = Uint8Array.from(rawScreenshot);
|
|
50
|
+
if (!isFirstIteration &&
|
|
51
|
+
actual &&
|
|
52
|
+
previous &&
|
|
53
|
+
areScreenshotsEqual(actual, previous)) {
|
|
54
|
+
return actual;
|
|
55
|
+
}
|
|
56
|
+
isFirstIteration = false;
|
|
57
|
+
}
|
|
58
|
+
return actual !== null && actual !== void 0 ? actual : Uint8Array.from(await target.screenshot(options));
|
|
59
|
+
}
|
|
60
|
+
exports.stablyPlaywrightMatchers = {
|
|
61
|
+
async toMatchScreenshotPrompt(received, condition, options) {
|
|
62
|
+
const target = (0, playwright_type_predicates_1.isPage)(received) ? received
|
|
63
|
+
: (0, playwright_type_predicates_1.isLocator)(received) ? received
|
|
64
|
+
: undefined;
|
|
65
|
+
if (!target) {
|
|
66
|
+
// Should never happen
|
|
67
|
+
throw new Error('toMatchScreenshotPrompt only supports Playwright Page and Locator instances.');
|
|
68
|
+
}
|
|
69
|
+
const targetType = (0, playwright_type_predicates_1.isPage)(target) ? 'page' : 'locator';
|
|
70
|
+
// Wait for two consecutive identical screenshots before sending to AI
|
|
71
|
+
const screenshot = await takeStableScreenshot(target, options);
|
|
72
|
+
const verifyResult = await (0, verify_prompt_1.verifyPrompt)({ prompt: condition, screenshot });
|
|
73
|
+
return {
|
|
74
|
+
pass: verifyResult.pass,
|
|
75
|
+
message: () => createFailureMessage({
|
|
76
|
+
targetType,
|
|
77
|
+
condition,
|
|
78
|
+
didPass: verifyResult.pass,
|
|
79
|
+
reason: verifyResult.reason,
|
|
80
|
+
isNot: this.isNot,
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import type { LocatorDescribeOptions } from './playwright-augment/augment';
|
|
3
|
+
import type { ExtractSchema, SchemaOutput } from './ai/extract';
|
|
4
|
+
import { augmentBrowser, augmentBrowserContext, augmentBrowserType, augmentLocator, augmentPage } from './playwright-augment/augment';
|
|
5
|
+
import { stablyPlaywrightMatchers } from './expect';
|
|
6
|
+
import { requireApiKey } from './runtime';
|
|
7
|
+
export { setApiKey } from './runtime';
|
|
8
|
+
export type { LocatorDescribeOptions } from './playwright-augment/augment';
|
|
9
|
+
export type { ExtractSchema, SchemaOutput } from './ai/extract';
|
|
10
|
+
export type ScreenshotPromptOptions = import('@playwright/test').PageAssertionsToHaveScreenshotOptions;
|
|
11
|
+
export { augmentBrowser, augmentBrowserContext, augmentBrowserType, augmentLocator, augmentPage, stablyPlaywrightMatchers, requireApiKey, };
|
|
12
|
+
export interface Expect<T = Page> {
|
|
13
|
+
toMatchScreenshotPrompt(condition: string, options?: ScreenshotPromptOptions): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
declare module 'playwright' {
|
|
16
|
+
interface Locator {
|
|
17
|
+
extract(prompt: string): Promise<string>;
|
|
18
|
+
extract<T extends ExtractSchema>(prompt: string, options: {
|
|
19
|
+
schema: T;
|
|
20
|
+
}): Promise<SchemaOutput<T>>;
|
|
21
|
+
describe(description: string, options?: LocatorDescribeOptions): Locator;
|
|
22
|
+
}
|
|
23
|
+
interface Page {
|
|
24
|
+
extract(prompt: string): Promise<string>;
|
|
25
|
+
extract<T extends ExtractSchema>(prompt: string, options: {
|
|
26
|
+
schema: T;
|
|
27
|
+
}): Promise<SchemaOutput<T>>;
|
|
28
|
+
}
|
|
29
|
+
interface BrowserContext {
|
|
30
|
+
agent(prompt: string, options: {
|
|
31
|
+
page: Page;
|
|
32
|
+
maxCycles?: number;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
success: boolean;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
interface Browser {
|
|
38
|
+
agent(prompt: string, options: {
|
|
39
|
+
page: Page;
|
|
40
|
+
maxCycles?: number;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
success: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.requireApiKey = exports.stablyPlaywrightMatchers = exports.augmentPage = exports.augmentLocator = exports.augmentBrowserType = exports.augmentBrowserContext = exports.augmentBrowser = exports.setApiKey = void 0;
|
|
4
|
+
const augment_1 = require("./playwright-augment/augment");
|
|
5
|
+
Object.defineProperty(exports, "augmentBrowser", { enumerable: true, get: function () { return augment_1.augmentBrowser; } });
|
|
6
|
+
Object.defineProperty(exports, "augmentBrowserContext", { enumerable: true, get: function () { return augment_1.augmentBrowserContext; } });
|
|
7
|
+
Object.defineProperty(exports, "augmentBrowserType", { enumerable: true, get: function () { return augment_1.augmentBrowserType; } });
|
|
8
|
+
Object.defineProperty(exports, "augmentLocator", { enumerable: true, get: function () { return augment_1.augmentLocator; } });
|
|
9
|
+
Object.defineProperty(exports, "augmentPage", { enumerable: true, get: function () { return augment_1.augmentPage; } });
|
|
10
|
+
const expect_1 = require("./expect");
|
|
11
|
+
Object.defineProperty(exports, "stablyPlaywrightMatchers", { enumerable: true, get: function () { return expect_1.stablyPlaywrightMatchers; } });
|
|
12
|
+
const runtime_1 = require("./runtime");
|
|
13
|
+
Object.defineProperty(exports, "requireApiKey", { enumerable: true, get: function () { return runtime_1.requireApiKey; } });
|
|
14
|
+
var runtime_2 = require("./runtime");
|
|
15
|
+
Object.defineProperty(exports, "setApiKey", { enumerable: true, get: function () { return runtime_2.setApiKey; } });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Browser, BrowserContext, BrowserType, Locator, Page } from 'playwright';
|
|
2
|
+
export interface LocatorDescribeOptions {
|
|
3
|
+
autoHeal?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function augmentLocator<T extends Locator>(locator: T): T;
|
|
6
|
+
export declare function augmentPage<T extends Page>(page: T): T;
|
|
7
|
+
export declare function augmentBrowserContext<T extends BrowserContext>(context: T): T;
|
|
8
|
+
export declare function augmentBrowser<T extends Browser>(browser: T): T;
|
|
9
|
+
export declare function augmentBrowserType<TBrowser extends Browser>(browserType: BrowserType<TBrowser>): BrowserType<TBrowser>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.augmentLocator = augmentLocator;
|
|
4
|
+
exports.augmentPage = augmentPage;
|
|
5
|
+
exports.augmentBrowserContext = augmentBrowserContext;
|
|
6
|
+
exports.augmentBrowser = augmentBrowser;
|
|
7
|
+
exports.augmentBrowserType = augmentBrowserType;
|
|
8
|
+
const extract_1 = require("./methods/extract");
|
|
9
|
+
const agent_1 = require("./methods/agent");
|
|
10
|
+
const LOCATOR_PATCHED = Symbol.for('stably.playwright.locatorPatched');
|
|
11
|
+
const LOCATOR_DESCRIBE_WRAPPED = Symbol.for('stably.playwright.locatorDescribeWrapped');
|
|
12
|
+
const PAGE_PATCHED = Symbol.for('stably.playwright.pagePatched');
|
|
13
|
+
const CONTEXT_PATCHED = Symbol.for('stably.playwright.contextPatched');
|
|
14
|
+
const BROWSER_PATCHED = Symbol.for('stably.playwright.browserPatched');
|
|
15
|
+
const BROWSER_TYPE_PATCHED = Symbol.for('stably.playwright.browserTypePatched');
|
|
16
|
+
function defineHiddenProperty(target, key, value) {
|
|
17
|
+
Object.defineProperty(target, key, {
|
|
18
|
+
value,
|
|
19
|
+
enumerable: false,
|
|
20
|
+
configurable: true,
|
|
21
|
+
writable: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function augmentLocator(locator) {
|
|
25
|
+
if (locator[LOCATOR_PATCHED]) {
|
|
26
|
+
return locator;
|
|
27
|
+
}
|
|
28
|
+
defineHiddenProperty(locator, 'extract', (0, extract_1.createLocatorExtract)(locator));
|
|
29
|
+
const markerTarget = locator;
|
|
30
|
+
if (typeof locator.describe === 'function' &&
|
|
31
|
+
!markerTarget[LOCATOR_DESCRIBE_WRAPPED]) {
|
|
32
|
+
const originalDescribe = locator.describe.bind(locator);
|
|
33
|
+
locator.describe = ((description, options) => {
|
|
34
|
+
void options;
|
|
35
|
+
const result = originalDescribe(description);
|
|
36
|
+
return result ? augmentLocator(result) : result;
|
|
37
|
+
});
|
|
38
|
+
defineHiddenProperty(locator, LOCATOR_DESCRIBE_WRAPPED, true);
|
|
39
|
+
}
|
|
40
|
+
defineHiddenProperty(locator, LOCATOR_PATCHED, true);
|
|
41
|
+
return locator;
|
|
42
|
+
}
|
|
43
|
+
function augmentPage(page) {
|
|
44
|
+
if (page[PAGE_PATCHED]) {
|
|
45
|
+
return page;
|
|
46
|
+
}
|
|
47
|
+
const originalLocator = page.locator.bind(page);
|
|
48
|
+
page.locator = ((...args) => {
|
|
49
|
+
const locator = originalLocator(...args);
|
|
50
|
+
return augmentLocator(locator);
|
|
51
|
+
});
|
|
52
|
+
defineHiddenProperty(page, 'extract', (0, extract_1.createPageExtract)(page));
|
|
53
|
+
defineHiddenProperty(page, PAGE_PATCHED, true);
|
|
54
|
+
return page;
|
|
55
|
+
}
|
|
56
|
+
function augmentBrowserContext(context) {
|
|
57
|
+
var _a;
|
|
58
|
+
if (context[CONTEXT_PATCHED]) {
|
|
59
|
+
return context;
|
|
60
|
+
}
|
|
61
|
+
const originalNewPage = context.newPage.bind(context);
|
|
62
|
+
context.newPage = (async (...args) => {
|
|
63
|
+
const page = await originalNewPage(...args);
|
|
64
|
+
return augmentPage(page);
|
|
65
|
+
});
|
|
66
|
+
const originalPages = (_a = context.pages) === null || _a === void 0 ? void 0 : _a.bind(context);
|
|
67
|
+
if (originalPages) {
|
|
68
|
+
context.pages = (() => originalPages().map((page) => augmentPage(page)));
|
|
69
|
+
}
|
|
70
|
+
if (!context.agent) {
|
|
71
|
+
defineHiddenProperty(context, 'agent', (0, agent_1.createAgentStub)());
|
|
72
|
+
}
|
|
73
|
+
defineHiddenProperty(context, CONTEXT_PATCHED, true);
|
|
74
|
+
return context;
|
|
75
|
+
}
|
|
76
|
+
function augmentBrowser(browser) {
|
|
77
|
+
if (browser[BROWSER_PATCHED]) {
|
|
78
|
+
return browser;
|
|
79
|
+
}
|
|
80
|
+
const originalNewContext = browser.newContext.bind(browser);
|
|
81
|
+
browser.newContext = (async (...args) => {
|
|
82
|
+
const context = await originalNewContext(...args);
|
|
83
|
+
return augmentBrowserContext(context);
|
|
84
|
+
});
|
|
85
|
+
const originalNewPage = browser.newPage.bind(browser);
|
|
86
|
+
browser.newPage = (async (...args) => {
|
|
87
|
+
const page = await originalNewPage(...args);
|
|
88
|
+
return augmentPage(page);
|
|
89
|
+
});
|
|
90
|
+
const originalContexts = browser.contexts.bind(browser);
|
|
91
|
+
browser.contexts = (() => originalContexts().map((context) => augmentBrowserContext(context)));
|
|
92
|
+
if (!browser.agent) {
|
|
93
|
+
defineHiddenProperty(browser, 'agent', (0, agent_1.createAgentStub)());
|
|
94
|
+
}
|
|
95
|
+
defineHiddenProperty(browser, BROWSER_PATCHED, true);
|
|
96
|
+
return browser;
|
|
97
|
+
}
|
|
98
|
+
function augmentBrowserType(browserType) {
|
|
99
|
+
var _a, _b, _c;
|
|
100
|
+
if (browserType[BROWSER_TYPE_PATCHED]) {
|
|
101
|
+
return browserType;
|
|
102
|
+
}
|
|
103
|
+
const originalLaunch = browserType.launch.bind(browserType);
|
|
104
|
+
browserType.launch = (async (...args) => {
|
|
105
|
+
const browser = await originalLaunch(...args);
|
|
106
|
+
return augmentBrowser(browser);
|
|
107
|
+
});
|
|
108
|
+
const originalConnect = (_a = browserType.connect) === null || _a === void 0 ? void 0 : _a.bind(browserType);
|
|
109
|
+
if (originalConnect) {
|
|
110
|
+
browserType.connect = (async (...args) => {
|
|
111
|
+
const browser = await originalConnect(...args);
|
|
112
|
+
return augmentBrowser(browser);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const originalConnectOverCDP = (_b = browserType.connectOverCDP) === null || _b === void 0 ? void 0 : _b.bind(browserType);
|
|
116
|
+
if (originalConnectOverCDP) {
|
|
117
|
+
browserType.connectOverCDP = (async (...args) => {
|
|
118
|
+
const browser = await originalConnectOverCDP(...args);
|
|
119
|
+
return augmentBrowser(browser);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const originalLaunchPersistentContext = (_c = browserType.launchPersistentContext) === null || _c === void 0 ? void 0 : _c.bind(browserType);
|
|
123
|
+
if (originalLaunchPersistentContext) {
|
|
124
|
+
browserType.launchPersistentContext = (async (...args) => {
|
|
125
|
+
const context = await originalLaunchPersistentContext(...args);
|
|
126
|
+
return augmentBrowserContext(context);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
defineHiddenProperty(browserType, BROWSER_TYPE_PATCHED, true);
|
|
130
|
+
return browserType;
|
|
131
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAgentStub = createAgentStub;
|
|
4
|
+
const runtime_1 = require("../../runtime");
|
|
5
|
+
function createAgentStub() {
|
|
6
|
+
return async (prompt, options) => {
|
|
7
|
+
(0, runtime_1.requireApiKey)();
|
|
8
|
+
void prompt;
|
|
9
|
+
void options;
|
|
10
|
+
return { success: true };
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
2
|
+
import { type ExtractSchema, type SchemaOutput } from '../../ai/extract';
|
|
3
|
+
type ExtractOptions<T extends ExtractSchema> = {
|
|
4
|
+
schema: T;
|
|
5
|
+
};
|
|
6
|
+
type ExtractMethod = {
|
|
7
|
+
(prompt: string): Promise<string>;
|
|
8
|
+
<T extends ExtractSchema>(prompt: string, options: ExtractOptions<T>): Promise<SchemaOutput<T>>;
|
|
9
|
+
};
|
|
10
|
+
type LocatorExtract = ExtractMethod;
|
|
11
|
+
type PageExtract = ExtractMethod;
|
|
12
|
+
export declare const createLocatorExtract: (locator: Locator) => LocatorExtract;
|
|
13
|
+
export declare const createPageExtract: (page: Page) => PageExtract;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPageExtract = exports.createLocatorExtract = void 0;
|
|
4
|
+
const extract_1 = require("../../ai/extract");
|
|
5
|
+
function createExtract(pageOrLocator) {
|
|
6
|
+
const impl = (async (prompt, options) => {
|
|
7
|
+
if (options === null || options === void 0 ? void 0 : options.schema) {
|
|
8
|
+
return (0, extract_1.extract)({
|
|
9
|
+
prompt,
|
|
10
|
+
schema: options.schema,
|
|
11
|
+
pageOrLocator,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return (0, extract_1.extract)({ prompt, pageOrLocator });
|
|
15
|
+
});
|
|
16
|
+
return impl;
|
|
17
|
+
}
|
|
18
|
+
const createLocatorExtract = (locator) => createExtract(locator);
|
|
19
|
+
exports.createLocatorExtract = createLocatorExtract;
|
|
20
|
+
const createPageExtract = (page) => createExtract(page);
|
|
21
|
+
exports.createPageExtract = createPageExtract;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isPage = isPage;
|
|
4
|
+
exports.isLocator = isLocator;
|
|
5
|
+
function isPage(candidate) {
|
|
6
|
+
return (typeof candidate === 'object' &&
|
|
7
|
+
candidate !== null &&
|
|
8
|
+
typeof candidate.screenshot === 'function' &&
|
|
9
|
+
typeof candidate.goto === 'function');
|
|
10
|
+
}
|
|
11
|
+
function isLocator(candidate) {
|
|
12
|
+
return (typeof candidate === 'object' &&
|
|
13
|
+
candidate !== null &&
|
|
14
|
+
typeof candidate.screenshot === 'function' &&
|
|
15
|
+
typeof candidate.nth === 'function');
|
|
16
|
+
}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setApiKey = setApiKey;
|
|
4
|
+
exports.getApiKey = getApiKey;
|
|
5
|
+
exports.requireApiKey = requireApiKey;
|
|
6
|
+
let configuredApiKey = process.env.STABLY_API_KEY;
|
|
7
|
+
function setApiKey(apiKey) {
|
|
8
|
+
configuredApiKey = apiKey;
|
|
9
|
+
}
|
|
10
|
+
function getApiKey() {
|
|
11
|
+
return configuredApiKey;
|
|
12
|
+
}
|
|
13
|
+
function requireApiKey() {
|
|
14
|
+
const apiKey = getApiKey();
|
|
15
|
+
if (!apiKey) {
|
|
16
|
+
throw new Error('Missing Stably API key. Call setApiKey(apiKey) or set the STABLY_API_KEY environment variable.');
|
|
17
|
+
}
|
|
18
|
+
return apiKey;
|
|
19
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stablyai/playwright-base",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared augmentation runtime for Stably Playwright wrappers",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@playwright/test": "^1.44.0",
|
|
13
|
+
"playwright": "^1.44.0",
|
|
14
|
+
"zod": "^4.0.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"zod": "^4.0.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.build.json"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
2
|
+
import * as z4 from 'zod/v4/core';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { requireApiKey } from '../runtime';
|
|
5
|
+
|
|
6
|
+
export type ExtractSchema = z4.$ZodType;
|
|
7
|
+
|
|
8
|
+
export type SchemaOutput<T extends ExtractSchema> = z4.output<T>;
|
|
9
|
+
|
|
10
|
+
type ExtractIssue = z4.$ZodIssue;
|
|
11
|
+
|
|
12
|
+
const EXTRACT_ENDPOINT = 'https://api.stably.ai/internal/v1/extract';
|
|
13
|
+
|
|
14
|
+
const zSuccess = z.object({ value: z.unknown() });
|
|
15
|
+
const zError = z.object({ error: z.string() });
|
|
16
|
+
|
|
17
|
+
class ExtractValidationError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
message: string,
|
|
20
|
+
readonly issues: ReadonlyArray<ExtractIssue>,
|
|
21
|
+
) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ExtractValidationError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function validateWithSchema<T extends ExtractSchema>(
|
|
28
|
+
schema: T,
|
|
29
|
+
value: unknown,
|
|
30
|
+
): Promise<SchemaOutput<T>> {
|
|
31
|
+
const result = await z4.safeParseAsync(schema, value);
|
|
32
|
+
if (!result.success) {
|
|
33
|
+
throw new ExtractValidationError('Validation failed', result.error.issues);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result.data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ExtractSubject = Page | Locator;
|
|
40
|
+
|
|
41
|
+
type BaseExtractArgs = {
|
|
42
|
+
prompt: string;
|
|
43
|
+
pageOrLocator: ExtractSubject;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type ExtractArgsWithSchema<T extends ExtractSchema> = BaseExtractArgs & {
|
|
47
|
+
schema: T;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export async function extract(args: BaseExtractArgs): Promise<string>;
|
|
51
|
+
export async function extract<T extends ExtractSchema>(
|
|
52
|
+
args: ExtractArgsWithSchema<T>,
|
|
53
|
+
): Promise<SchemaOutput<T>>;
|
|
54
|
+
export async function extract<T extends ExtractSchema>({
|
|
55
|
+
prompt,
|
|
56
|
+
pageOrLocator,
|
|
57
|
+
schema,
|
|
58
|
+
}: BaseExtractArgs & { schema?: T }): Promise<string | SchemaOutput<T>> {
|
|
59
|
+
const jsonSchema = schema ? z4.toJSONSchema(schema) : undefined;
|
|
60
|
+
|
|
61
|
+
const apiKey = requireApiKey();
|
|
62
|
+
|
|
63
|
+
const form = new FormData();
|
|
64
|
+
form.append('prompt', prompt);
|
|
65
|
+
if (jsonSchema) {
|
|
66
|
+
form.append('jsonSchema', JSON.stringify(jsonSchema));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const pngBuffer = await pageOrLocator.screenshot({ type: 'png' }); // Buffer
|
|
70
|
+
const u8 = Uint8Array.from(pngBuffer); // strips Buffer type → plain Uint8Array
|
|
71
|
+
const blob = new Blob([u8], { type: 'image/png' });
|
|
72
|
+
form.append('image', blob, 'screenshot.png');
|
|
73
|
+
|
|
74
|
+
const response = await fetch(EXTRACT_ENDPOINT, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${apiKey}`,
|
|
78
|
+
},
|
|
79
|
+
body: form,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const parsed = await response.json().catch(() => undefined as unknown);
|
|
83
|
+
|
|
84
|
+
if (response.ok) {
|
|
85
|
+
const { value } = zSuccess.parse(parsed);
|
|
86
|
+
return (
|
|
87
|
+
schema ? await validateWithSchema(schema, value)
|
|
88
|
+
: typeof value === 'string' ? value
|
|
89
|
+
: JSON.stringify(value)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const err = zError.safeParse(parsed);
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Extract failed (${response.status})${err.success ? `: ${err.data.error}` : ''}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const PROMPT_ASSERTION_ENDPOINT = 'https://api.stably.ai/internal/v1/assert';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { requireApiKey } from '../runtime';
|
|
5
|
+
|
|
6
|
+
const zSuccess = z.object({
|
|
7
|
+
success: z.boolean(),
|
|
8
|
+
reason: z.string().optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const zError = z.object({
|
|
12
|
+
error: z.string(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export async function verifyPrompt({
|
|
16
|
+
prompt,
|
|
17
|
+
screenshot,
|
|
18
|
+
}: {
|
|
19
|
+
prompt: string;
|
|
20
|
+
screenshot: Uint8Array;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
pass: boolean;
|
|
23
|
+
reason?: string;
|
|
24
|
+
}> {
|
|
25
|
+
const apiKey = requireApiKey();
|
|
26
|
+
|
|
27
|
+
const form = new FormData();
|
|
28
|
+
form.append('prompt', prompt);
|
|
29
|
+
const u8 = Uint8Array.from(screenshot);
|
|
30
|
+
const blob = new Blob([u8], { type: 'image/png' });
|
|
31
|
+
form.append('image', blob, 'screenshot.png');
|
|
32
|
+
|
|
33
|
+
const response = await fetch(PROMPT_ASSERTION_ENDPOINT, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${apiKey}`,
|
|
37
|
+
},
|
|
38
|
+
body: form,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const parsed = await response.json().catch(() => undefined as unknown);
|
|
42
|
+
|
|
43
|
+
if (response.ok) {
|
|
44
|
+
const { success, reason } = zSuccess.parse(parsed);
|
|
45
|
+
return {
|
|
46
|
+
pass: success,
|
|
47
|
+
reason,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const err = zError.safeParse(parsed);
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Verify prompt failed (${response.status})${
|
|
54
|
+
err.success ? `: ${err.data.error}` : ''
|
|
55
|
+
}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
package/src/expect.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
2
|
+
import type { ScreenshotPromptOptions } from './index';
|
|
3
|
+
|
|
4
|
+
import { isLocator, isPage } from './playwright-type-predicates';
|
|
5
|
+
|
|
6
|
+
import { verifyPrompt } from './ai/verify-prompt';
|
|
7
|
+
|
|
8
|
+
type VerifyTargetType = 'page' | 'locator';
|
|
9
|
+
|
|
10
|
+
type MatcherContext = {
|
|
11
|
+
isNot: boolean;
|
|
12
|
+
message?: () => string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function createFailureMessage({
|
|
16
|
+
targetType,
|
|
17
|
+
condition,
|
|
18
|
+
didPass,
|
|
19
|
+
isNot,
|
|
20
|
+
reason,
|
|
21
|
+
}: {
|
|
22
|
+
targetType: VerifyTargetType;
|
|
23
|
+
condition: string;
|
|
24
|
+
didPass: boolean;
|
|
25
|
+
isNot: boolean;
|
|
26
|
+
reason?: string;
|
|
27
|
+
}): string {
|
|
28
|
+
const expectation = isNot ? 'not to satisfy' : 'to satisfy';
|
|
29
|
+
const result = didPass ? 'it did' : 'it did not';
|
|
30
|
+
|
|
31
|
+
let message = `Expected ${targetType} ${expectation} ${JSON.stringify(condition)}, but ${result}.`;
|
|
32
|
+
if (reason) {
|
|
33
|
+
message += `\n\nReason: ${reason}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return message;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function areScreenshotsEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
40
|
+
if (a.byteLength !== b.byteLength) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (let index = 0; index < a.byteLength; index += 1) {
|
|
45
|
+
if (a[index] !== b[index]) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function takeStableScreenshot(
|
|
54
|
+
target: Page | Locator,
|
|
55
|
+
options?: ScreenshotPromptOptions,
|
|
56
|
+
): Promise<Uint8Array> {
|
|
57
|
+
const page = isPage(target) ? target : target.page();
|
|
58
|
+
|
|
59
|
+
// Use a small budget for stabilization within the overall assertion timeout.
|
|
60
|
+
// We allocate up to 25% of the total timeout (bounded between 300ms and 2000ms).
|
|
61
|
+
const totalTimeout =
|
|
62
|
+
(options as { timeout?: number } | undefined)?.timeout ?? 5000;
|
|
63
|
+
// Budget is 25% of the total timeout
|
|
64
|
+
const stabilizationBudgetMs = Math.floor(totalTimeout * 0.25);
|
|
65
|
+
const stabilityBudgetMs = Math.min(
|
|
66
|
+
2000,
|
|
67
|
+
Math.max(300, stabilizationBudgetMs),
|
|
68
|
+
);
|
|
69
|
+
const deadline = Date.now() + stabilityBudgetMs;
|
|
70
|
+
|
|
71
|
+
let actual: Uint8Array | undefined;
|
|
72
|
+
let previous: Uint8Array | undefined;
|
|
73
|
+
const pollIntervals = [0, 100, 250, 500];
|
|
74
|
+
let isFirstIteration = true;
|
|
75
|
+
|
|
76
|
+
while (true) {
|
|
77
|
+
if (Date.now() >= deadline) break;
|
|
78
|
+
const delay = pollIntervals.length ? pollIntervals.shift()! : 1000;
|
|
79
|
+
if (delay) {
|
|
80
|
+
await page.waitForTimeout(delay);
|
|
81
|
+
}
|
|
82
|
+
previous = actual;
|
|
83
|
+
const rawScreenshot = await target.screenshot(options);
|
|
84
|
+
actual = Uint8Array.from(rawScreenshot);
|
|
85
|
+
if (
|
|
86
|
+
!isFirstIteration &&
|
|
87
|
+
actual &&
|
|
88
|
+
previous &&
|
|
89
|
+
areScreenshotsEqual(actual, previous)
|
|
90
|
+
) {
|
|
91
|
+
return actual;
|
|
92
|
+
}
|
|
93
|
+
isFirstIteration = false;
|
|
94
|
+
}
|
|
95
|
+
return actual ?? Uint8Array.from(await target.screenshot(options));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const stablyPlaywrightMatchers = {
|
|
99
|
+
async toMatchScreenshotPrompt(
|
|
100
|
+
this: MatcherContext,
|
|
101
|
+
received: Page | Locator,
|
|
102
|
+
condition: string,
|
|
103
|
+
options?: ScreenshotPromptOptions,
|
|
104
|
+
) {
|
|
105
|
+
const target =
|
|
106
|
+
isPage(received) ? received
|
|
107
|
+
: isLocator(received) ? received
|
|
108
|
+
: undefined;
|
|
109
|
+
if (!target) {
|
|
110
|
+
// Should never happen
|
|
111
|
+
throw new Error(
|
|
112
|
+
'toMatchScreenshotPrompt only supports Playwright Page and Locator instances.',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const targetType: VerifyTargetType = isPage(target) ? 'page' : 'locator';
|
|
116
|
+
|
|
117
|
+
// Wait for two consecutive identical screenshots before sending to AI
|
|
118
|
+
const screenshot = await takeStableScreenshot(target, options);
|
|
119
|
+
|
|
120
|
+
const verifyResult = await verifyPrompt({ prompt: condition, screenshot });
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
pass: verifyResult.pass,
|
|
124
|
+
message: () =>
|
|
125
|
+
createFailureMessage({
|
|
126
|
+
targetType,
|
|
127
|
+
condition,
|
|
128
|
+
didPass: verifyResult.pass,
|
|
129
|
+
reason: verifyResult.reason,
|
|
130
|
+
isNot: this.isNot,
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
} as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import type { LocatorDescribeOptions } from './playwright-augment/augment';
|
|
3
|
+
import type { ExtractSchema, SchemaOutput } from './ai/extract';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
augmentBrowser,
|
|
7
|
+
augmentBrowserContext,
|
|
8
|
+
augmentBrowserType,
|
|
9
|
+
augmentLocator,
|
|
10
|
+
augmentPage,
|
|
11
|
+
} from './playwright-augment/augment';
|
|
12
|
+
import { stablyPlaywrightMatchers } from './expect';
|
|
13
|
+
import { requireApiKey } from './runtime';
|
|
14
|
+
|
|
15
|
+
export { setApiKey } from './runtime';
|
|
16
|
+
|
|
17
|
+
export type { LocatorDescribeOptions } from './playwright-augment/augment';
|
|
18
|
+
export type { ExtractSchema, SchemaOutput } from './ai/extract';
|
|
19
|
+
export type ScreenshotPromptOptions =
|
|
20
|
+
import('@playwright/test').PageAssertionsToHaveScreenshotOptions;
|
|
21
|
+
export {
|
|
22
|
+
augmentBrowser,
|
|
23
|
+
augmentBrowserContext,
|
|
24
|
+
augmentBrowserType,
|
|
25
|
+
augmentLocator,
|
|
26
|
+
augmentPage,
|
|
27
|
+
stablyPlaywrightMatchers,
|
|
28
|
+
requireApiKey,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface Expect<T = Page> {
|
|
32
|
+
toMatchScreenshotPrompt(
|
|
33
|
+
condition: string,
|
|
34
|
+
options?: ScreenshotPromptOptions,
|
|
35
|
+
): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
declare module 'playwright' {
|
|
39
|
+
interface Locator {
|
|
40
|
+
extract(prompt: string): Promise<string>;
|
|
41
|
+
extract<T extends ExtractSchema>(
|
|
42
|
+
prompt: string,
|
|
43
|
+
options: { schema: T },
|
|
44
|
+
): Promise<SchemaOutput<T>>;
|
|
45
|
+
describe(description: string, options?: LocatorDescribeOptions): Locator;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Page {
|
|
49
|
+
extract(prompt: string): Promise<string>;
|
|
50
|
+
extract<T extends ExtractSchema>(
|
|
51
|
+
prompt: string,
|
|
52
|
+
options: { schema: T },
|
|
53
|
+
): Promise<SchemaOutput<T>>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface BrowserContext {
|
|
57
|
+
agent(
|
|
58
|
+
prompt: string,
|
|
59
|
+
options: { page: Page; maxCycles?: number },
|
|
60
|
+
): Promise<{ success: boolean }>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Browser {
|
|
64
|
+
agent(
|
|
65
|
+
prompt: string,
|
|
66
|
+
options: { page: Page; maxCycles?: number },
|
|
67
|
+
): Promise<{ success: boolean }>;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Browser,
|
|
3
|
+
BrowserContext,
|
|
4
|
+
BrowserType,
|
|
5
|
+
Locator,
|
|
6
|
+
Page,
|
|
7
|
+
} from 'playwright';
|
|
8
|
+
|
|
9
|
+
import { createLocatorExtract, createPageExtract } from './methods/extract';
|
|
10
|
+
import { createAgentStub } from './methods/agent';
|
|
11
|
+
|
|
12
|
+
export interface LocatorDescribeOptions {
|
|
13
|
+
autoHeal?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LOCATOR_PATCHED = Symbol.for('stably.playwright.locatorPatched');
|
|
17
|
+
const LOCATOR_DESCRIBE_WRAPPED = Symbol.for(
|
|
18
|
+
'stably.playwright.locatorDescribeWrapped',
|
|
19
|
+
);
|
|
20
|
+
const PAGE_PATCHED = Symbol.for('stably.playwright.pagePatched');
|
|
21
|
+
const CONTEXT_PATCHED = Symbol.for('stably.playwright.contextPatched');
|
|
22
|
+
const BROWSER_PATCHED = Symbol.for('stably.playwright.browserPatched');
|
|
23
|
+
const BROWSER_TYPE_PATCHED = Symbol.for('stably.playwright.browserTypePatched');
|
|
24
|
+
|
|
25
|
+
function defineHiddenProperty<T, K extends PropertyKey>(
|
|
26
|
+
target: T,
|
|
27
|
+
key: K,
|
|
28
|
+
value: unknown,
|
|
29
|
+
): void {
|
|
30
|
+
Object.defineProperty(target as unknown as object, key, {
|
|
31
|
+
value,
|
|
32
|
+
enumerable: false,
|
|
33
|
+
configurable: true,
|
|
34
|
+
writable: true,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function augmentLocator<T extends Locator>(locator: T): T {
|
|
39
|
+
if (
|
|
40
|
+
(locator as unknown as { [LOCATOR_PATCHED]?: boolean })[LOCATOR_PATCHED]
|
|
41
|
+
) {
|
|
42
|
+
return locator;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
defineHiddenProperty(locator, 'extract', createLocatorExtract(locator));
|
|
46
|
+
|
|
47
|
+
const markerTarget = locator as unknown as Record<PropertyKey, unknown>;
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
typeof locator.describe === 'function' &&
|
|
51
|
+
!markerTarget[LOCATOR_DESCRIBE_WRAPPED]
|
|
52
|
+
) {
|
|
53
|
+
const originalDescribe = locator.describe.bind(locator);
|
|
54
|
+
locator.describe = ((
|
|
55
|
+
description: string,
|
|
56
|
+
options?: LocatorDescribeOptions,
|
|
57
|
+
) => {
|
|
58
|
+
void options;
|
|
59
|
+
const result = originalDescribe(description);
|
|
60
|
+
return result ? augmentLocator(result as Locator) : result;
|
|
61
|
+
}) as Locator['describe'];
|
|
62
|
+
|
|
63
|
+
defineHiddenProperty(locator, LOCATOR_DESCRIBE_WRAPPED, true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
defineHiddenProperty(locator, LOCATOR_PATCHED, true);
|
|
67
|
+
|
|
68
|
+
return locator;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function augmentPage<T extends Page>(page: T): T {
|
|
72
|
+
if ((page as unknown as { [PAGE_PATCHED]?: boolean })[PAGE_PATCHED]) {
|
|
73
|
+
return page;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const originalLocator = page.locator.bind(page);
|
|
77
|
+
page.locator = ((...args: Parameters<Page['locator']>) => {
|
|
78
|
+
const locator = originalLocator(...args);
|
|
79
|
+
return augmentLocator(locator);
|
|
80
|
+
}) as Page['locator'];
|
|
81
|
+
|
|
82
|
+
defineHiddenProperty(page, 'extract', createPageExtract(page));
|
|
83
|
+
defineHiddenProperty(page, PAGE_PATCHED, true);
|
|
84
|
+
|
|
85
|
+
return page;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function augmentBrowserContext<T extends BrowserContext>(context: T): T {
|
|
89
|
+
if (
|
|
90
|
+
(context as unknown as { [CONTEXT_PATCHED]?: boolean })[CONTEXT_PATCHED]
|
|
91
|
+
) {
|
|
92
|
+
return context;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const originalNewPage = context.newPage.bind(context);
|
|
96
|
+
context.newPage = (async (...args: Parameters<BrowserContext['newPage']>) => {
|
|
97
|
+
const page = await originalNewPage(...args);
|
|
98
|
+
return augmentPage(page);
|
|
99
|
+
}) as BrowserContext['newPage'];
|
|
100
|
+
|
|
101
|
+
const originalPages = context.pages?.bind(context);
|
|
102
|
+
if (originalPages) {
|
|
103
|
+
context.pages = (() =>
|
|
104
|
+
originalPages().map((page) =>
|
|
105
|
+
augmentPage(page),
|
|
106
|
+
)) as BrowserContext['pages'];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!(context as unknown as { agent?: unknown }).agent) {
|
|
110
|
+
defineHiddenProperty(context, 'agent', createAgentStub());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
defineHiddenProperty(context, CONTEXT_PATCHED, true);
|
|
114
|
+
|
|
115
|
+
return context;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function augmentBrowser<T extends Browser>(browser: T): T {
|
|
119
|
+
if (
|
|
120
|
+
(browser as unknown as { [BROWSER_PATCHED]?: boolean })[BROWSER_PATCHED]
|
|
121
|
+
) {
|
|
122
|
+
return browser;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const originalNewContext = browser.newContext.bind(browser);
|
|
126
|
+
browser.newContext = (async (...args: Parameters<Browser['newContext']>) => {
|
|
127
|
+
const context = await originalNewContext(...args);
|
|
128
|
+
return augmentBrowserContext(context);
|
|
129
|
+
}) as Browser['newContext'];
|
|
130
|
+
|
|
131
|
+
const originalNewPage = browser.newPage.bind(browser);
|
|
132
|
+
browser.newPage = (async (...args: Parameters<Browser['newPage']>) => {
|
|
133
|
+
const page = await originalNewPage(...args);
|
|
134
|
+
return augmentPage(page);
|
|
135
|
+
}) as Browser['newPage'];
|
|
136
|
+
|
|
137
|
+
const originalContexts = browser.contexts.bind(browser);
|
|
138
|
+
browser.contexts = (() =>
|
|
139
|
+
originalContexts().map((context) =>
|
|
140
|
+
augmentBrowserContext(context),
|
|
141
|
+
)) as Browser['contexts'];
|
|
142
|
+
|
|
143
|
+
if (!(browser as unknown as { agent?: unknown }).agent) {
|
|
144
|
+
defineHiddenProperty(browser, 'agent', createAgentStub());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
defineHiddenProperty(browser, BROWSER_PATCHED, true);
|
|
148
|
+
|
|
149
|
+
return browser;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function augmentBrowserType<TBrowser extends Browser>(
|
|
153
|
+
browserType: BrowserType<TBrowser>,
|
|
154
|
+
): BrowserType<TBrowser> {
|
|
155
|
+
if (
|
|
156
|
+
(browserType as unknown as { [BROWSER_TYPE_PATCHED]?: boolean })[
|
|
157
|
+
BROWSER_TYPE_PATCHED
|
|
158
|
+
]
|
|
159
|
+
) {
|
|
160
|
+
return browserType;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const originalLaunch = browserType.launch.bind(browserType);
|
|
164
|
+
browserType.launch = (async (
|
|
165
|
+
...args: Parameters<BrowserType<TBrowser>['launch']>
|
|
166
|
+
) => {
|
|
167
|
+
const browser = await originalLaunch(...args);
|
|
168
|
+
return augmentBrowser(browser);
|
|
169
|
+
}) as BrowserType<TBrowser>['launch'];
|
|
170
|
+
|
|
171
|
+
const originalConnect = browserType.connect?.bind(browserType);
|
|
172
|
+
if (originalConnect) {
|
|
173
|
+
browserType.connect = (async (
|
|
174
|
+
...args: Parameters<NonNullable<BrowserType<TBrowser>['connect']>>
|
|
175
|
+
) => {
|
|
176
|
+
const browser = await originalConnect(...args);
|
|
177
|
+
return augmentBrowser(browser);
|
|
178
|
+
}) as NonNullable<BrowserType<TBrowser>['connect']>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const originalConnectOverCDP = browserType.connectOverCDP?.bind(browserType);
|
|
182
|
+
if (originalConnectOverCDP) {
|
|
183
|
+
browserType.connectOverCDP = (async (
|
|
184
|
+
...args: Parameters<NonNullable<BrowserType<TBrowser>['connectOverCDP']>>
|
|
185
|
+
) => {
|
|
186
|
+
const browser = await originalConnectOverCDP(...args);
|
|
187
|
+
return augmentBrowser(browser);
|
|
188
|
+
}) as NonNullable<BrowserType<TBrowser>['connectOverCDP']>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const originalLaunchPersistentContext =
|
|
192
|
+
browserType.launchPersistentContext?.bind(browserType);
|
|
193
|
+
if (originalLaunchPersistentContext) {
|
|
194
|
+
browserType.launchPersistentContext = (async (
|
|
195
|
+
...args: Parameters<
|
|
196
|
+
NonNullable<BrowserType<TBrowser>['launchPersistentContext']>
|
|
197
|
+
>
|
|
198
|
+
) => {
|
|
199
|
+
const context = await originalLaunchPersistentContext(...args);
|
|
200
|
+
return augmentBrowserContext(context);
|
|
201
|
+
}) as NonNullable<BrowserType<TBrowser>['launchPersistentContext']>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
defineHiddenProperty(browserType, BROWSER_TYPE_PATCHED, true);
|
|
205
|
+
|
|
206
|
+
return browserType;
|
|
207
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { requireApiKey } from '../../runtime';
|
|
3
|
+
|
|
4
|
+
type AgentOptions = {
|
|
5
|
+
page: Page;
|
|
6
|
+
maxCycles?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function createAgentStub(): (
|
|
10
|
+
prompt: string,
|
|
11
|
+
options: AgentOptions,
|
|
12
|
+
) => Promise<{ success: boolean }> {
|
|
13
|
+
return async (prompt: string, options: AgentOptions) => {
|
|
14
|
+
requireApiKey();
|
|
15
|
+
void prompt;
|
|
16
|
+
void options;
|
|
17
|
+
return { success: true };
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
2
|
+
import {
|
|
3
|
+
type ExtractSchema,
|
|
4
|
+
type SchemaOutput,
|
|
5
|
+
extract,
|
|
6
|
+
} from '../../ai/extract';
|
|
7
|
+
|
|
8
|
+
type ExtractOptions<T extends ExtractSchema> = {
|
|
9
|
+
schema: T;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type ExtractMethod = {
|
|
13
|
+
(prompt: string): Promise<string>;
|
|
14
|
+
<T extends ExtractSchema>(
|
|
15
|
+
prompt: string,
|
|
16
|
+
options: ExtractOptions<T>,
|
|
17
|
+
): Promise<SchemaOutput<T>>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type LocatorExtract = ExtractMethod;
|
|
21
|
+
type PageExtract = ExtractMethod;
|
|
22
|
+
|
|
23
|
+
type ExtractSubject = Locator | Page;
|
|
24
|
+
|
|
25
|
+
function createExtract(pageOrLocator: ExtractSubject): ExtractMethod {
|
|
26
|
+
const impl = (async (
|
|
27
|
+
prompt: string,
|
|
28
|
+
options?: ExtractOptions<ExtractSchema>,
|
|
29
|
+
) => {
|
|
30
|
+
if (options?.schema) {
|
|
31
|
+
return extract({
|
|
32
|
+
prompt,
|
|
33
|
+
schema: options.schema,
|
|
34
|
+
pageOrLocator,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return extract({ prompt, pageOrLocator });
|
|
39
|
+
}) as ExtractMethod;
|
|
40
|
+
|
|
41
|
+
return impl;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const createLocatorExtract = (locator: Locator): LocatorExtract =>
|
|
45
|
+
createExtract(locator);
|
|
46
|
+
|
|
47
|
+
export const createPageExtract = (page: Page): PageExtract =>
|
|
48
|
+
createExtract(page);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Page, Locator } from 'playwright';
|
|
2
|
+
|
|
3
|
+
export function isPage(candidate: unknown): candidate is Page {
|
|
4
|
+
return (
|
|
5
|
+
typeof candidate === 'object' &&
|
|
6
|
+
candidate !== null &&
|
|
7
|
+
typeof (candidate as Page).screenshot === 'function' &&
|
|
8
|
+
typeof (candidate as Page).goto === 'function'
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isLocator(candidate: unknown): candidate is Locator {
|
|
13
|
+
return (
|
|
14
|
+
typeof candidate === 'object' &&
|
|
15
|
+
candidate !== null &&
|
|
16
|
+
typeof (candidate as Locator).screenshot === 'function' &&
|
|
17
|
+
typeof (candidate as Locator).nth === 'function'
|
|
18
|
+
);
|
|
19
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
let configuredApiKey: string | undefined = process.env.STABLY_API_KEY;
|
|
2
|
+
|
|
3
|
+
export function setApiKey(apiKey: string): void {
|
|
4
|
+
configuredApiKey = apiKey;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getApiKey(): string | undefined {
|
|
8
|
+
return configuredApiKey;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function requireApiKey(): string {
|
|
12
|
+
const apiKey = getApiKey();
|
|
13
|
+
if (!apiKey) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'Missing Stably API key. Call setApiKey(apiKey) or set the STABLY_API_KEY environment variable.',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return apiKey;
|
|
19
|
+
}
|