@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 +4 -1
- package/scripts/download-binary.js +31 -6
- package/src/classes/ResponseV2.d.ts +7 -0
- package/src/classes/ResponseV2.js +22 -0
- package/src/classes/SkyrampClient.js +6 -17
- package/src/classes/SmartFixture.d.ts +9 -0
- package/src/classes/SmartFixture.js +114 -0
- package/src/classes/SmartPlaywright.js +160 -6
- package/src/index.d.ts +1 -0
- package/src/index.js +2 -0
- package/src/workspace.d.ts +1 -1
- package/src/workspace.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyramp/skyramp",
|
|
3
|
-
"version": "1.3.
|
|
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'; //
|
|
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.${
|
|
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
|
-
|
|
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();
|
|
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();
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
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,
|
package/src/workspace.d.ts
CHANGED
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(),
|