@skyramp/skyramp 1.3.23 → 1.3.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/skyramp",
3
- "version": "1.3.23",
3
+ "version": "1.3.25",
4
4
  "description": "module for leveraging skyramp cli functionality",
5
5
  "scripts": {
6
6
  "lint": "eslint 'src/**/*.js' 'src/**/*.ts' --fix",
@@ -29,6 +29,9 @@
29
29
  "koffi": "^2.15.0",
30
30
  "zod": "^3.25.3"
31
31
  },
32
+ "peerDependencies": {
33
+ "@playwright/test": ">=1.58.2"
34
+ },
32
35
  "devDependencies": {
33
36
  "@typescript-eslint/eslint-plugin": "^6.14.0",
34
37
  "@typescript-eslint/parser": "^6.14.0",
@@ -22,7 +22,8 @@ function log(level, message) {
22
22
  }
23
23
 
24
24
  let S3_PRIVATE = process.env.S3_PRIVATE || false; // Set to true if libraries are in a private S3 bucket
25
- const AWS_REGION = process.env.AWS_REGION || 'us-west-2'; // Set your AWS region
25
+ const AWS_REGION = process.env.AWS_REGION || 'us-west-2'; // Used only for private S3 bucket access
26
+ const PUBLIC_BUCKET_REGION = 'us-west-2'; // Fixed: public bucket region (immune to user's AWS_REGION)
26
27
  const SKIP_DOWNLOAD = process.env.SKIP_DOWNLOAD || false; // Set to true to skip the download process
27
28
  const PUBLIC_BUCKET_NAME = process.env.INT_LIBRARY_BUCKET || 'skyramp-public'; // Set to your S3 public bucket name
28
29
  const PUBLIC_LIBRARY_PATH = process.env.INT_LIBRARY_PATH || `release/v${require('../package.json').version}/lib`; // Set to your S3 public library root path
@@ -68,7 +69,7 @@ const prefix = 'skyramp';
68
69
  const binaryFilename = `${prefix}-${platform}-${arch}.${ext}`;
69
70
  const headerFilename = `${prefix}-${platform}-${arch}.h`;
70
71
 
71
- const baseUrl = `https://${PUBLIC_BUCKET_NAME}.s3.${AWS_REGION}.amazonaws.com/${PUBLIC_LIBRARY_PATH}/`;
72
+ const baseUrl = `https://${PUBLIC_BUCKET_NAME}.s3.${PUBLIC_BUCKET_REGION}.amazonaws.com/${PUBLIC_LIBRARY_PATH}/`;
72
73
  const localDir = path.join(__dirname, '..', 'lib');
73
74
 
74
75
  const files = [
@@ -89,11 +90,24 @@ async function calculateMD5(filePath) {
89
90
  });
90
91
  }
91
92
 
92
- async function getETag(url) {
93
+ const MAX_REDIRECTS = 5;
94
+
95
+ async function getETag(url, redirectCount = 0) {
93
96
  return new Promise((resolve, reject) => {
94
97
  const req = https.get(url, { method: 'HEAD', timeout: HEAD_TIMEOUT_MS }, res => {
98
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
99
+ res.resume();
100
+ if (redirectCount >= MAX_REDIRECTS) {
101
+ reject(new Error(`Too many redirects for ${url}`));
102
+ return;
103
+ }
104
+ const redirectUrl = new URL(res.headers.location, url).toString();
105
+ log('info', `Following redirect (${res.statusCode}) to ${redirectUrl}`);
106
+ resolve(getETag(redirectUrl, redirectCount + 1));
107
+ return;
108
+ }
95
109
  if (res.statusCode !== 200) {
96
- res.resume(); // Drain response to free the socket
110
+ res.resume();
97
111
  reject(new Error(`HTTP ${res.statusCode}: ${url}`));
98
112
  return;
99
113
  }
@@ -113,7 +127,7 @@ async function getETag(url) {
113
127
  });
114
128
  }
115
129
 
116
- async function download(url, dest, options = {}) {
130
+ async function download(url, dest, options = {}, redirectCount = 0) {
117
131
  await fs.promises.mkdir(path.dirname(dest), { recursive: true });
118
132
 
119
133
  const { session, bucket, s3Key } = options;
@@ -150,8 +164,19 @@ async function download(url, dest, options = {}) {
150
164
  };
151
165
 
152
166
  const req = https.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, res => {
167
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
168
+ res.resume();
169
+ if (redirectCount >= MAX_REDIRECTS) {
170
+ fail(new Error(`Too many redirects for ${url}`));
171
+ return;
172
+ }
173
+ const redirectUrl = new URL(res.headers.location, url).toString();
174
+ log('info', `Following redirect (${res.statusCode}) to ${redirectUrl}`);
175
+ resolve(download(redirectUrl, dest, options, redirectCount + 1));
176
+ return;
177
+ }
153
178
  if (res.statusCode !== 200) {
154
- res.resume(); // Drain response to free the socket
179
+ res.resume();
155
180
  const error = new Error(`HTTP ${res.statusCode}: ${url}`);
156
181
  log('error', `Failed to download ${url}: ${error.message}`);
157
182
  fail(error);
@@ -22,4 +22,11 @@ export declare class ResponseV2 {
22
22
  */
23
23
  constructor(options?: ResponseV2Options);
24
24
  toYaml(): string;
25
+ toJson(): string;
26
+ /**
27
+ * Prints the response status in a formatted way with color coding.
28
+ * Uses the C library function PrintResponseV2Status for output.
29
+ * @param expectedStatusCode - The expected HTTP status code as a string (supports wildcards like "2xx", "20x")
30
+ */
31
+ print(expectedStatusCode: string): void;
25
32
  }
@@ -1,4 +1,8 @@
1
1
  const yaml = require('js-yaml');
2
+ const lib = require('../lib');
3
+
4
+ // C library function binding for PrintResponseV2Status
5
+ const printResponseV2Status = lib.func('PrintResponseV2Status', 'void', ['str', 'str']);
2
6
 
3
7
  /**
4
8
  * Represents a REST response.
@@ -95,6 +99,24 @@ class ResponseV2 {
95
99
  }, {});
96
100
  return JSON.stringify(jsonObject, null, 2);
97
101
  }
102
+
103
+ /**
104
+ * Prints the response status in a formatted way with color coding.
105
+ * Uses the C library function PrintResponseV2Status for output.
106
+ *
107
+ * @param {string} expectedStatusCode - The expected HTTP status code as a string (supports wildcards like "2xx", "20x")
108
+ */
109
+ print(expectedStatusCode) {
110
+ if (!expectedStatusCode || typeof expectedStatusCode !== 'string') {
111
+ throw new Error('Expected status code must be a non-empty string');
112
+ }
113
+
114
+ // Convert ResponseV2 to JSON
115
+ const responseJson = this.toJson();
116
+
117
+ // Call the C library function
118
+ printResponseV2Status(responseJson, expectedStatusCode);
119
+ }
98
120
  }
99
121
 
100
122
 
@@ -48,8 +48,6 @@ const runTesterStartWrapperv1 = lib.func('runTesterStartWrapperWithGlobalHeaders
48
48
  const applyMockDescriptionWrapper = lib.func('applyMockDescriptionWrapper', 'string', ['string', 'string', 'string', 'string', 'string', 'string', 'string', 'string']);
49
49
  const applyMockObjectWrapper = lib.func('applyMockObjectWrapper', 'string', ['string', 'string', 'string', 'string', 'string', 'string', 'string', 'string', 'bool']);
50
50
  const removeMocksObjectWrapper = lib.func('removeMocksObjectWrapper', 'string', []);
51
- // NPM only: for VS code extension use
52
- const initTargetWrapper = lib.func('initTargetWrapper', 'string', ['string']);
53
51
  const deployTargetWrapper = lib.func('deployTargetWrapper', 'string', ['string', 'string', 'string', 'string', 'string', 'string', 'bool']);
54
52
  const deleteTargetWrapper = lib.func('deleteTargetWrapper', 'string', ['string', 'string', 'string', 'string', 'string']);
55
53
 
@@ -92,6 +90,9 @@ const generateRestTestWrapper = lib.func('generateRestTestWrapper', 'string', [
92
90
  'string', // playwrightSaveStoragePath
93
91
  'string', // browser
94
92
  'string', // device
93
+ 'string', // loadExtension
94
+ 'string', // userDataDir
95
+ 'bool', // pressSequentially
95
96
  'string', // loadCount
96
97
  'string', // loadDuration
97
98
  'string', // loadNumThreads
@@ -675,21 +676,6 @@ class SkyrampClient {
675
676
  });
676
677
  }
677
678
 
678
- // NPM only: for VS code extension use
679
- async initTarget(taretName) {
680
- return new Promise((resolve, reject) => {
681
- initTargetWrapper.async(taretName, (err, res) => {
682
- if (err) {
683
- reject(err);
684
- } else if (res) {
685
- reject(new Error(res));
686
- } else {
687
- resolve();
688
- }
689
- });
690
- });
691
- }
692
-
693
679
  // NPM only: for VS code extension use
694
680
  async deployTarget(targetDescription, namespace, workerImage = '', localImage = false) {
695
681
  if (this.kubeConfigPath === null) {
@@ -942,6 +928,9 @@ class SkyrampClient {
942
928
  options.playwrightSaveStoragePath || "",
943
929
  options.browser || "",
944
930
  options.device || "",
931
+ options.loadExtension || "",
932
+ options.userDataDir || "",
933
+ options.pressSequentially || false,
945
934
  options.loadCount || "0",
946
935
  options.loadDuration || "0",
947
936
  options.loadNumThreads || "0",
@@ -0,0 +1,9 @@
1
+ import type { test as baseTest } from '@playwright/test';
2
+
3
+ /**
4
+ * Playwright `test` object that automatically launches a persistent Chromium
5
+ * context with the loaded extension(s) when PLAYWRIGHT_LOAD_EXTENSION is set
6
+ * in the environment (PLAYWRIGHT_USER_DATA_DIR optionally pins the profile).
7
+ * Without those env vars it behaves identically to Playwright's default `test`.
8
+ */
9
+ export const test: typeof baseTest;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Skyramp's Playwright test fixture.
3
+ *
4
+ * Re-exports a `test` object that behaves like Playwright's default `test`
5
+ * unless extension-loading env vars are set, in which case it overrides the
6
+ * `context` and `page` fixtures to launch a persistent Chromium context with
7
+ * the given extension and (optional) user-data directory.
8
+ *
9
+ * Recognised env vars:
10
+ * - PLAYWRIGHT_LOAD_EXTENSION
11
+ * Comma-separated absolute paths to unpacked extensions to load.
12
+ * - PLAYWRIGHT_USER_DATA_DIR
13
+ * Persistent profile directory. Optional; when omitted Playwright
14
+ * creates a fresh temp directory each run.
15
+ *
16
+ * Without these env vars the export is a passthrough — generated specs that
17
+ * `import { test } from '@skyramp/skyramp'` keep working in plain test runs.
18
+ * (The names are deliberately the same as the env vars the Skyramp MCP
19
+ * recorder reads, so a single env block covers recording + replay.)
20
+ *
21
+ * The `ignoreDefaultArgs` list mirrors what the Skyramp MCP recorder does at
22
+ * launch time: Playwright's Chromium defaults include `--disable-extensions`
23
+ * and `--disable-component-extensions-with-background-pages`, both of which
24
+ * silently break MV3 extension loading. Strip them so the extension's service
25
+ * worker actually starts.
26
+ */
27
+
28
+ // Resolve @playwright/test starting from the consumer's project root,
29
+ // not from skyramp's install location. When skyramp is installed in a
30
+ // different node_modules tree than the consumer (e.g. globally), default
31
+ // resolution would find skyramp's own copy of @playwright/test — a
32
+ // physically different file from the one the test runner already loaded
33
+ // — and Playwright throws "Requiring @playwright/test second time".
34
+ // Anchoring resolution at process.cwd() makes Node walk up the consumer's
35
+ // tree first so we share the runner's instance.
36
+ const { test: baseTest, chromium } = require(loadPlaywrightTestPath());
37
+
38
+ function loadPlaywrightTestPath() {
39
+ try {
40
+ return require.resolve('@playwright/test', { paths: [process.cwd()] });
41
+ } catch {
42
+ // Fallback for setups where @playwright/test is reachable from
43
+ // skyramp's tree but not from process.cwd() (development checkouts,
44
+ // monorepos where tests run from the package dir).
45
+ return '@playwright/test';
46
+ }
47
+ }
48
+
49
+ function envOrUndefined(name) {
50
+ const v = process.env[name];
51
+ return v && v.trim() !== '' ? v.trim() : undefined;
52
+ }
53
+
54
+ function getExtensionPaths() {
55
+ const raw = envOrUndefined('PLAYWRIGHT_LOAD_EXTENSION');
56
+ if (!raw) return [];
57
+ return raw.split(',').map(s => s.trim()).filter(Boolean);
58
+ }
59
+
60
+ function getUserDataDir() {
61
+ return envOrUndefined('PLAYWRIGHT_USER_DATA_DIR');
62
+ }
63
+
64
+ const test = baseTest.extend({
65
+ context: async ({ browser, browserName }, use) => {
66
+ const extensions = getExtensionPaths();
67
+ const userDataDir = getUserDataDir();
68
+
69
+ if (extensions.length === 0) {
70
+ // No extension requested — delegate to the inherited `browser` fixture
71
+ // so playwright.config (browserName, use.viewport, use.headless,
72
+ // launchOptions, etc.) and Playwright's per-worker browser pool are
73
+ // honored.
74
+ const ctx = await browser.newContext();
75
+ await use(ctx);
76
+ await ctx.close();
77
+ return;
78
+ }
79
+
80
+ // Playwright's extension-loading APIs (--load-extension, launchPersistentContext
81
+ // with extension args) are Chromium-only. Fail fast rather than silently
82
+ // launching Chromium and overriding the project's browser choice.
83
+ if (browserName !== 'chromium') {
84
+ throw new Error(
85
+ `PLAYWRIGHT_LOAD_EXTENSION is set but the project uses '${browserName}'. ` +
86
+ `Extension loading is only supported in Chromium today.`
87
+ );
88
+ }
89
+
90
+ const joined = extensions.join(',');
91
+ const ctx = await chromium.launchPersistentContext(userDataDir ?? '', {
92
+ headless: false, // MV3 service workers don't run headless
93
+ args: [
94
+ `--disable-extensions-except=${joined}`,
95
+ `--load-extension=${joined}`,
96
+ ],
97
+ ignoreDefaultArgs: [
98
+ '--disable-extensions',
99
+ '--disable-component-extensions-with-background-pages',
100
+ ],
101
+ });
102
+ await use(ctx);
103
+ await ctx.close();
104
+ },
105
+
106
+ page: async ({ context }, use) => {
107
+ // Reuse an existing page if the extension spawned one (welcome / lock
108
+ // screen tabs are common); otherwise open a fresh tab.
109
+ const page = context.pages()[0] ?? (await context.newPage());
110
+ await use(page);
111
+ },
112
+ });
113
+
114
+ module.exports = { test };
@@ -1,8 +1,20 @@
1
1
  /* global window */
2
- const { expect: playwrightExpect } = require('@playwright/test');
2
+ // See SmartFixture.js for the rationale: resolve @playwright/test from
3
+ // the consumer's project root so we share the runner's instance and
4
+ // avoid "Requiring @playwright/test second time" when skyramp is
5
+ // installed in a different node_modules tree.
6
+ function loadPlaywrightTestPath() {
7
+ try {
8
+ return require.resolve('@playwright/test', { paths: [process.cwd()] });
9
+ } catch {
10
+ return '@playwright/test';
11
+ }
12
+ }
13
+ const { expect: playwrightExpect } = require(loadPlaywrightTestPath());
3
14
  const lib = require('../lib');
4
15
  const koffi = require('koffi');
5
16
  const fs = require('fs');
17
+ const os = require('os');
6
18
  const path = require('path');
7
19
  const { PDF_VIEWER_INJECTION_SCRIPT } = require('../pdfViewer');
8
20
 
@@ -1259,6 +1271,89 @@ class SkyrampPlaywrightPage {
1259
1271
  return this._page.waitForResponse(arg, newOptions)
1260
1272
  }
1261
1273
 
1274
+ /**
1275
+ * Returns a Proxy around the underlying BrowserContext that auto-wraps
1276
+ * any Page coming out of newPage() or waitForEvent('page') with
1277
+ * newSkyrampPlaywrightPage. Lets codegen emit
1278
+ * const page2 = await page.context().newPage();
1279
+ * and get a fully-wrapped SkyrampPlaywrightPage without the
1280
+ * setDefaultTimeout + manual reassignment dance.
1281
+ *
1282
+ * All other context methods are forwarded unchanged.
1283
+ */
1284
+ context() {
1285
+ const ctx = this._page.context();
1286
+ const testInfo = this._testInfo;
1287
+ return new Proxy(ctx, {
1288
+ get(target, prop) {
1289
+ if (prop === 'newPage') {
1290
+ return async (...args) => {
1291
+ const rawPage = await target.newPage(...args);
1292
+ return newSkyrampPlaywrightPage(rawPage, testInfo);
1293
+ };
1294
+ }
1295
+ if (prop === 'waitForEvent') {
1296
+ return async (event, ...args) => {
1297
+ const result = await target.waitForEvent(event, ...args);
1298
+ if (event === 'page' && result)
1299
+ return newSkyrampPlaywrightPage(result, testInfo);
1300
+ return result;
1301
+ };
1302
+ }
1303
+ const value = Reflect.get(target, prop, target);
1304
+ if (typeof value === 'function')
1305
+ return value.bind(target);
1306
+ return value;
1307
+ },
1308
+ });
1309
+ }
1310
+
1311
+ /**
1312
+ * Poll the browser context for a page whose URL contains `urlMatch`,
1313
+ * returning a wrapped SkyrampPlaywrightPage once one attaches. Throws
1314
+ * if no match appears within `timeoutMs` (default 15s).
1315
+ *
1316
+ * Used by Skyramp's codegen for `openPage` events that have no recorded
1317
+ * triggering action (extension-auto-opened tabs: 1Password onboarding,
1318
+ * Watchtower notifications, etc.) where the Promise.all wrap pattern
1319
+ * isn't applicable. Replaces the bare `context.pages().find(...)` lookup
1320
+ * which races against page attachment after profile init / extension
1321
+ * service-worker boot.
1322
+ */
1323
+ async waitForPage(urlMatch, timeoutMs = 15000) {
1324
+ const ctx = this._page.context();
1325
+ const deadline = Date.now() + timeoutMs;
1326
+ while (Date.now() < deadline) {
1327
+ const found = ctx.pages().find(p => p.url().includes(urlMatch));
1328
+ if (found) return newSkyrampPlaywrightPage(found, this._testInfo);
1329
+ await new Promise(r => setTimeout(r, 250));
1330
+ }
1331
+ throw new Error(`Page matching "${urlMatch}" not found within ${timeoutMs}ms`);
1332
+ }
1333
+
1334
+ /**
1335
+ * Poll this page's frame tree for a frame whose URL contains `urlMatch`,
1336
+ * returning the Frame once it attaches. Throws if no match appears
1337
+ * within `timeoutMs` (default 15s).
1338
+ *
1339
+ * Used for chrome-extension iframes (1Password's save dialog, autofill
1340
+ * menu, modal etc.) hosted in closed shadow roots. Standard
1341
+ * page.locator('iframe').contentFrame() can't pierce a closed shadow
1342
+ * root, but page.frames() (CDP-backed) sees every frame regardless.
1343
+ * Replaces the bare `page.frames().find(...)` lookup which races against
1344
+ * iframe attachment after a form submit (the save dialog re-attaches on
1345
+ * the destination page after navigation tears down the original).
1346
+ */
1347
+ async waitForFrame(urlMatch, timeoutMs = 15000) {
1348
+ const deadline = Date.now() + timeoutMs;
1349
+ while (Date.now() < deadline) {
1350
+ const found = this._page.frames().find(f => f.url().includes(urlMatch));
1351
+ if (found) return found;
1352
+ await new Promise(r => setTimeout(r, 250));
1353
+ }
1354
+ throw new Error(`Frame matching "${urlMatch}" not found within ${timeoutMs}ms`);
1355
+ }
1356
+
1262
1357
  async goto(url, options) {
1263
1358
  // Ensure PDF setup is complete before navigating
1264
1359
  if (this._pdfSetupPromise) {
@@ -1267,6 +1362,28 @@ class SkyrampPlaywrightPage {
1267
1362
 
1268
1363
  const transformedUrl = transformUrlForDocker(url);
1269
1364
  const result = await this._page.goto(transformedUrl, options);
1365
+
1366
+ // Extension content-script settle. page.goto returns when the page's
1367
+ // `load` event fires, but a Chrome extension's content script attaches
1368
+ // via chrome.tabs.onUpdated which can fire *after* `load`. A click
1369
+ // immediately following goto can hit before the extension is listening
1370
+ // (1Password's inline menu, autofill detector, etc.). When the
1371
+ // BrowserContext has an extension loaded, give the extension a moment
1372
+ // to attach before returning.
1373
+ try {
1374
+ const ctx = this._page.context();
1375
+ const sws = typeof ctx.serviceWorkers === 'function' ? ctx.serviceWorkers() : [];
1376
+ const bps = typeof ctx.backgroundPages === 'function' ? ctx.backgroundPages() : [];
1377
+ const hasExtension = sws.some(w => w.url && w.url().startsWith('chrome-extension://'))
1378
+ || bps.some(p => p.url && p.url().startsWith('chrome-extension://'));
1379
+ if (hasExtension) {
1380
+ debug('[SmartPlaywright] extension detected, waiting 500ms for content-script attach');
1381
+ await this._page.waitForTimeout(500);
1382
+ }
1383
+ } catch (e) {
1384
+ debug(`[SmartPlaywright] extension detection failed (${e.message}); skipping settle wait`);
1385
+ }
1386
+
1270
1387
  const content = await this._page.content();
1271
1388
  if (hasJavascriptWrapper(content)) {
1272
1389
  debug(`javascript download detected when visiting ${this._page.url()}`);
@@ -1342,6 +1459,30 @@ function newSkyrampPlaywrightPage(page, testInfo) {
1342
1459
  return new SkyrampPlaywrightPage(page, testInfo);
1343
1460
  }
1344
1461
 
1462
+ /**
1463
+ * Bridge inline `style` (CSS string) -> `stylePath` (CSS file) for the duration
1464
+ * of a single toHaveScreenshot call. Playwright's expect(...).toHaveScreenshot
1465
+ * ignores inline `style` and only reads `stylePath`, so we materialize the
1466
+ * string to a unique temp directory created via mkdtempSync. The directory is
1467
+ * removed in a finally block so two unrelated specs cannot share a stale path.
1468
+ */
1469
+ async function withStylePath(options, fn) {
1470
+ if (!options || typeof options.style !== 'string' || !options.style.trim())
1471
+ return await fn(options);
1472
+ if (options.stylePath)
1473
+ return await fn(options);
1474
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skyramp-style-'));
1475
+ const filePath = path.join(dir, 'style.css');
1476
+ fs.writeFileSync(filePath, options.style);
1477
+ const rest = { ...options };
1478
+ delete rest.style;
1479
+ try {
1480
+ return await fn({ ...rest, stylePath: filePath });
1481
+ } finally {
1482
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
1483
+ }
1484
+ }
1485
+
1345
1486
  /**
1346
1487
  * Wrapper class for Playwright's expect that provides auto-baseline generation for screenshots.
1347
1488
  * Check if snapshot exists, create if missing, then compare. Enabling single-run screenshots
@@ -1370,7 +1511,16 @@ class SkyrampPageAssertions {
1370
1511
  }
1371
1512
 
1372
1513
  /**
1373
- * Auto-generates baseline screenshots if missing
1514
+ * Auto-generates baseline screenshots if missing.
1515
+ *
1516
+ * `options.style` is honored at both stages:
1517
+ * - page.screenshot()/locator.screenshot() (used to write the first baseline)
1518
+ * accepts `style` as inline CSS directly.
1519
+ * - expect(...).toHaveScreenshot() ignores inline `style` and only honors
1520
+ * `stylePath`. We bridge that here by writing the inline string to a
1521
+ * unique temp file (mkdtempSync + per-call directory so two parallel
1522
+ * specs cannot share a stale path) and forwarding it as stylePath.
1523
+ * The temp file is deleted in a finally block.
1374
1524
  */
1375
1525
  async toHaveScreenshot(nameOrOptions, options) {
1376
1526
  // wait for some time before taking snapshot
@@ -1378,12 +1528,14 @@ class SkyrampPageAssertions {
1378
1528
 
1379
1529
  // If auto-baseline disabled or no testInfo, use standard Playwright behavior
1380
1530
  if (!this._autoBaseline || !this._testInfo) {
1381
- return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions, options);
1531
+ return await withStylePath(options, (forwardedOptions) =>
1532
+ this._playwrightExpectation.toHaveScreenshot(nameOrOptions, forwardedOptions));
1382
1533
  }
1383
1534
 
1384
1535
  // Only handle string names (auto-generated names not supported)
1385
1536
  if (typeof nameOrOptions !== 'string') {
1386
- return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions, options);
1537
+ return await withStylePath(options, (forwardedOptions) =>
1538
+ this._playwrightExpectation.toHaveScreenshot(nameOrOptions, forwardedOptions));
1387
1539
  }
1388
1540
 
1389
1541
  // Clip coordinates are document-relative (viewport coords + scrollX/Y at record time).
@@ -1437,8 +1589,10 @@ class SkyrampPageAssertions {
1437
1589
  debug(`Generated baseline: ${snapshotPath}`);
1438
1590
  }
1439
1591
 
1440
- // Baseline exists (or just created): assert normally
1441
- return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions, adjustedOptions);
1592
+ // Baseline exists (or just created): assert normally. Bridge inline style
1593
+ // to stylePath because toHaveScreenshot does not honor inline style.
1594
+ return await withStylePath(adjustedOptions, (forwardedOptions) =>
1595
+ this._playwrightExpectation.toHaveScreenshot(nameOrOptions, forwardedOptions));
1442
1596
  }
1443
1597
  }
1444
1598
 
package/src/index.d.ts CHANGED
@@ -19,5 +19,6 @@ export * from "./classes/AsyncTestStatus";
19
19
  export * from "./utils";
20
20
  export * from "./function";
21
21
  export * from "./classes/SmartPlaywright";
22
+ export * from "./classes/SmartFixture";
22
23
  export * from "./classes/GoJSDiagram";
23
24
  export * from "./workspace";
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ const MockV2 = require('./classes/MockV2');
20
20
  const { getValue, getResponseValue, checkSchema, iterate, pushToolEvent, getBaseUrl } = require('./utils');
21
21
  const { checkStatusCode, checkRequestPayload } = require('./function');
22
22
  const { newSkyrampPlaywrightPage, expect } = require('./classes/SmartPlaywright');
23
+ const { test } = require('./classes/SmartFixture');
23
24
  const GoJSDiagram = require('./classes/GoJSDiagram');
24
25
  const {
25
26
  workspaceConfigSchema,
@@ -63,6 +64,7 @@ module.exports = {
63
64
  newSkyrampPlaywrightPage,
64
65
  GoJSDiagram,
65
66
  expect,
67
+ test,
66
68
  workspaceConfigSchema,
67
69
  serviceSchema,
68
70
  WorkspaceConfigManager,
@@ -16,7 +16,7 @@ export interface ServiceApi {
16
16
  }
17
17
 
18
18
  export interface ServiceRuntimeDetails {
19
- serverStartCommand: string;
19
+ serverStartCommand?: string;
20
20
  runtime: "local" | "docker" | "k8s";
21
21
  dockerNetwork?: string;
22
22
  k8sNamespace?: string;
package/src/workspace.js CHANGED
@@ -41,7 +41,7 @@ const serviceSchema = z.object({
41
41
  .optional(),
42
42
  runtimeDetails: z
43
43
  .object({
44
- serverStartCommand: z.string(),
44
+ serverStartCommand: z.string().optional(),
45
45
  runtime: z.enum(['local', 'docker', 'k8s']),
46
46
  dockerNetwork: z.string().optional(),
47
47
  k8sNamespace: z.string().optional(),