@stablyai/playwright-base 2.0.16 → 2.1.1

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 CHANGED
@@ -26,7 +26,7 @@ const isObject = (value) => {
26
26
 
27
27
  const SDK_METADATA_HEADERS = {
28
28
  "X-Client-Name": "stably-playwright-sdk-js",
29
- "X-Client-Version": "2.0.16"
29
+ "X-Client-Version": "2.1.1"
30
30
  };
31
31
 
32
32
  const PROMPT_ASSERTION_PATH = "internal/v2/assert";
@@ -59,6 +59,7 @@ const parseErrorResponse = (value) => {
59
59
  return typeof error !== "string" ? void 0 : { error };
60
60
  };
61
61
  async function verifyPrompt({
62
+ model,
62
63
  pageMetadata,
63
64
  prompt,
64
65
  screenshot
@@ -70,6 +71,9 @@ async function verifyPrompt({
70
71
  ...pageMetadata?.title ? { pageTitle: pageMetadata.title } : {},
71
72
  ...pageMetadata?.url ? { pageUrl: pageMetadata.url } : {}
72
73
  };
74
+ if (model) {
75
+ metadata.model = model;
76
+ }
73
77
  form.append("metadata", JSON.stringify(metadata));
74
78
  const u8 = Uint8Array.from(screenshot);
75
79
  const blob = new Blob([u8], { type: "image/png" });
@@ -365,22 +369,41 @@ Reason: ${reason}`;
365
369
  return message;
366
370
  }
367
371
  const stablyPlaywrightMatchers = {
368
- async toMatchScreenshotPrompt(received, condition, options) {
372
+ /**
373
+ * Asserts that the page or locator satisfies a natural language condition using AI vision.
374
+ *
375
+ * Takes a screenshot of the target and uses AI to verify whether the specified condition is met.
376
+ * The AI analyzes the visual content and provides reasoning for its determination.
377
+ *
378
+ * @param condition - A natural language description of what should be true about the page/locator
379
+ * @param options - Optional screenshot options (e.g., fullPage, timeout)
380
+ *
381
+ * @example
382
+ * ```typescript
383
+ * // Assert page content
384
+ * await expect(page).aiAssert('The login form is visible with email and password fields');
385
+ *
386
+ * // Assert locator content
387
+ * await expect(page.locator('.header')).aiAssert('The navigation menu contains a logout button');
388
+ * ```
389
+ */
390
+ async aiAssert(received, condition, options) {
369
391
  const target = isPage(received) ? received : isLocator(received) ? received : void 0;
370
392
  if (!target) {
371
393
  throw new Error(
372
- "toMatchScreenshotPrompt only supports Playwright Page and Locator instances."
394
+ "aiAssert only supports Playwright Page and Locator instances."
373
395
  );
374
396
  }
375
397
  const targetType = isPage(target) ? "page" : "locator";
376
398
  const screenshot = await takeStableScreenshot(target, options);
377
399
  const verifyResult = await verifyPrompt({
400
+ model: options?.model,
378
401
  pageMetadata: isPage(target) ? { title: await target.title(), url: target.url() } : void 0,
379
402
  prompt: condition,
380
403
  screenshot
381
404
  });
382
405
  const testInfo = test.info();
383
- const sanitizedName = condition.trim().toLowerCase().replace(/[^\w]+/g, "_").replace(/^_+|_+$/g, "").slice(0, MAX_ATTACHMENT_NAME_LENGTH) || "toMatchScreenshotPrompt";
406
+ const sanitizedName = condition.trim().toLowerCase().replace(/[^\w]+/g, "_").replace(/^_+|_+$/g, "").slice(0, MAX_ATTACHMENT_NAME_LENGTH) || "aiAssert";
384
407
  testInfo.attachments.push(
385
408
  {
386
409
  body: Buffer.from(
@@ -414,9 +437,20 @@ const stablyPlaywrightMatchers = {
414
437
  reason: verifyResult.reason,
415
438
  targetType
416
439
  }),
417
- name: "toMatchScreenshotPrompt",
440
+ name: "aiAssert",
418
441
  pass: verifyResult.pass
419
442
  };
443
+ },
444
+ /**
445
+ * @deprecated Use `aiAssert` instead. This method will be removed in a future version.
446
+ */
447
+ async toMatchScreenshotPrompt(received, condition, options) {
448
+ return stablyPlaywrightMatchers.aiAssert.call(
449
+ this,
450
+ received,
451
+ condition,
452
+ options
453
+ );
420
454
  }
421
455
  };
422
456
 
@@ -837,7 +871,7 @@ const isExtractionResponse = (value) => {
837
871
  }
838
872
  return value.success === false && typeof value.error === "string";
839
873
  };
840
- const isErrorResponse = (value) => {
874
+ const isErrorResponse$1 = (value) => {
841
875
  return isObject(value) && typeof value.error === "string";
842
876
  };
843
877
  class ExtractValidationError extends Error {
@@ -858,6 +892,7 @@ async function validateWithSchema(schema, value) {
858
892
  return result.data;
859
893
  }
860
894
  async function extract({
895
+ model,
861
896
  pageOrLocator,
862
897
  prompt,
863
898
  schema
@@ -878,6 +913,9 @@ async function extract({
878
913
  if (jsonSchema) {
879
914
  form.append("jsonSchema", JSON.stringify(jsonSchema));
880
915
  }
916
+ if (model) {
917
+ form.append("model", model);
918
+ }
881
919
  const pngBuffer = await pageOrLocator.screenshot({ type: "png" });
882
920
  const u8 = Uint8Array.from(pngBuffer);
883
921
  const blob = new Blob([u8], { type: "image/png" });
@@ -910,7 +948,7 @@ async function extract({
910
948
  });
911
949
  return schema ? await validateWithSchema(schema, value) : typeof value === "string" ? value : JSON.stringify(value);
912
950
  }
913
- throw new Error(isErrorResponse(raw) ? raw.error : "Extract failed");
951
+ throw new Error(isErrorResponse$1(raw) ? raw.error : "Extract failed");
914
952
  });
915
953
  }
916
954
 
@@ -918,18 +956,97 @@ function createExtract(pageOrLocator) {
918
956
  const impl = (async (prompt, options) => {
919
957
  if (options?.schema) {
920
958
  return extract({
959
+ model: options.model,
921
960
  pageOrLocator,
922
961
  prompt,
923
962
  schema: options.schema
924
963
  });
925
964
  }
926
- return extract({ pageOrLocator, prompt });
965
+ return extract({ model: options?.model, pageOrLocator, prompt });
927
966
  });
928
967
  return impl;
929
968
  }
930
969
  const createLocatorExtract = (locator) => createExtract(locator);
931
970
  const createPageExtract = (page) => createExtract(page);
932
971
 
972
+ const GET_LOCATOR_PATH = "internal/v3/get-aria-refs";
973
+ const EMPTY_LOCATOR_SELECTOR = "__stably_empty_locator__";
974
+ function getEndpoint() {
975
+ const baseUrl = process.env.STABLY_API_URL || "https://api.stably.ai";
976
+ return new URL(GET_LOCATOR_PATH, baseUrl).toString();
977
+ }
978
+ const isAPIResponse = (value) => {
979
+ if (!isObject(value)) {
980
+ return false;
981
+ }
982
+ return Array.isArray(value.refs) && value.refs.every((ref) => typeof ref === "string") && typeof value.reason === "string";
983
+ };
984
+ const isErrorResponse = (value) => {
985
+ return isObject(value) && typeof value.error === "string";
986
+ };
987
+ async function getLocatorsByAI(page, prompt, options) {
988
+ return await test.step(`[GetLocatorByAI] ${prompt}`, async (stepInfo) => {
989
+ const apiKey = requireApiKey();
990
+ const pageWithSnapshot = page;
991
+ if (typeof pageWithSnapshot._snapshotForAI !== "function") {
992
+ throw new Error(
993
+ "getLocatorsByAI requires Playwright v1.54.1 or higher. Please upgrade your Playwright version."
994
+ );
995
+ }
996
+ const snapshotResult = await pageWithSnapshot._snapshotForAI();
997
+ const ariaSnapshot = typeof snapshotResult === "string" ? snapshotResult : snapshotResult.full;
998
+ const response = await (async () => {
999
+ try {
1000
+ return await fetch(getEndpoint(), {
1001
+ body: JSON.stringify({
1002
+ ariaSnapshot,
1003
+ prompt,
1004
+ ...options?.model && { model: options.model }
1005
+ }),
1006
+ headers: {
1007
+ ...SDK_METADATA_HEADERS,
1008
+ Authorization: `Bearer ${apiKey}`,
1009
+ "Content-Type": "application/json"
1010
+ },
1011
+ method: "POST"
1012
+ });
1013
+ } catch (error) {
1014
+ throw new Error(
1015
+ `getLocatorsByAI failed to connect to Stably API: ${error instanceof Error ? error.message : "Unknown error"}`
1016
+ );
1017
+ }
1018
+ })();
1019
+ const raw = await response.json().catch(() => void 0);
1020
+ if (!response.ok) {
1021
+ const err = isErrorResponse(raw) ? raw.error : "Unknown error";
1022
+ throw new Error(`getLocatorsByAI failed (${response.status}): ${err}`);
1023
+ }
1024
+ if (!isAPIResponse(raw)) {
1025
+ throw new Error("getLocatorsByAI returned unexpected response shape");
1026
+ }
1027
+ await stepInfo.attach("[GetLocatorByAI] reason", {
1028
+ body: raw.reason,
1029
+ contentType: "text/plain"
1030
+ });
1031
+ const locator = raw.refs.length === 0 ? (
1032
+ // Empty locator that matches nothing
1033
+ page.locator(EMPTY_LOCATOR_SELECTOR)
1034
+ ) : (
1035
+ // Combine refs using .or()
1036
+ raw.refs.map((ref) => page.locator(`aria-ref=${ref}`)).reduce((acc, loc) => acc.or(loc))
1037
+ );
1038
+ return {
1039
+ count: raw.refs.length,
1040
+ locator,
1041
+ reason: raw.reason
1042
+ };
1043
+ });
1044
+ }
1045
+
1046
+ const createPageGetLocatorByAI = (page) => {
1047
+ return (prompt, options) => getLocatorsByAI(page, prompt, options);
1048
+ };
1049
+
933
1050
  const LOCATOR_PATCHED = Symbol.for("stably.playwright.locatorPatched");
934
1051
  const PAGE_PATCHED = Symbol.for("stably.playwright.pagePatched");
935
1052
  const CONTEXT_PATCHED = Symbol.for("stably.playwright.contextPatched");
@@ -961,6 +1078,7 @@ function augmentPage(page) {
961
1078
  return augmentLocator(locator);
962
1079
  });
963
1080
  defineHiddenProperty(page, "extract", createPageExtract(page));
1081
+ defineHiddenProperty(page, "getLocatorsByAI", createPageGetLocatorByAI(page));
964
1082
  defineHiddenProperty(page, PAGE_PATCHED, true);
965
1083
  return page;
966
1084
  }