@apica-io/asm-playwright-runner 1.0.0-dev.6 → 1.0.0-dev.8
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/playwright-script.spec.js +61 -0
- package/dist/runner.js +5 -15
- package/package.json +1 -1
- package/src/actions.js +33 -0
- package/src/cli/index.js +60 -0
- package/src/lib/helper.js +67 -0
- package/src/lib/parser.js +283 -0
- package/src/model/runnerConfig.js +24 -0
- package/src/model/traceModel.js +1 -0
- package/src/runner.js +225 -0
- package/tsconfig.json +6 -9
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
12
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
|
13
|
+
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
14
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
15
|
+
function step(op) {
|
|
16
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
17
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
18
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
19
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
20
|
+
switch (op[0]) {
|
|
21
|
+
case 0: case 1: t = op; break;
|
|
22
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
23
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
24
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
25
|
+
default:
|
|
26
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
27
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
28
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
29
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
30
|
+
if (t[2]) _.ops.pop();
|
|
31
|
+
_.trys.pop(); continue;
|
|
32
|
+
}
|
|
33
|
+
op = body.call(thisArg, _);
|
|
34
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
35
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
var test_1 = require("playwright/test");
|
|
40
|
+
function example(page) {
|
|
41
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
42
|
+
return __generator(this, function (_a) {
|
|
43
|
+
switch (_a.label) {
|
|
44
|
+
case 0: return [4 /*yield*/, page.goto("https://playwright.dev/")];
|
|
45
|
+
case 1:
|
|
46
|
+
_a.sent();
|
|
47
|
+
return [4 /*yield*/, (0, test_1.expect)(page).toHaveTitle(/Playwright/)];
|
|
48
|
+
case 2:
|
|
49
|
+
_a.sent();
|
|
50
|
+
return [4 /*yield*/, page.getByRole("link", { name: "Get started" }).click()];
|
|
51
|
+
case 3:
|
|
52
|
+
_a.sent();
|
|
53
|
+
return [4 /*yield*/, (0, test_1.expect)(page.getByRole("heading", { name: "Installation" })).toBeVisible()];
|
|
54
|
+
case 4:
|
|
55
|
+
_a.sent();
|
|
56
|
+
return [2 /*return*/];
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
exports.default = example;
|
package/dist/runner.js
CHANGED
|
@@ -234,26 +234,16 @@ class PlaywrightRunner {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
async detectScriptType(collectionPath) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (typeof mod.default === "function") {
|
|
241
|
-
this.logger.debug("Detected plain Playwright script (default export).");
|
|
242
|
-
return runnerConfig_1.ScriptType.PLAYWRIGHT;
|
|
243
|
-
}
|
|
237
|
+
const fileContent = fs_1.default.readFileSync(collectionPath, "utf-8");
|
|
238
|
+
if (fileContent.includes("export default")) {
|
|
239
|
+
return runnerConfig_1.ScriptType.PLAYWRIGHT;
|
|
244
240
|
}
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
if (fileContent.includes("test(")) {
|
|
242
|
+
return runnerConfig_1.ScriptType.PLAYWRIGHT_TEST;
|
|
247
243
|
}
|
|
248
244
|
if (collectionPath.endsWith(".json")) {
|
|
249
|
-
this.logger.debug("Detected JSON flow.");
|
|
250
245
|
return runnerConfig_1.ScriptType.JSON;
|
|
251
246
|
}
|
|
252
|
-
const fileContent = fs_1.default.readFileSync(collectionPath, "utf-8");
|
|
253
|
-
if (fileContent.includes("test(")) {
|
|
254
|
-
this.logger.debug("Detected Playwright Test file.");
|
|
255
|
-
return runnerConfig_1.ScriptType.PLAYWRIGHT_TEST;
|
|
256
|
-
}
|
|
257
247
|
return undefined;
|
|
258
248
|
}
|
|
259
249
|
async close() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apica-io/asm-playwright-runner",
|
|
3
|
-
"version": "1.0.0-dev.
|
|
3
|
+
"version": "1.0.0-dev.8",
|
|
4
4
|
"description": "CLI wrapper for Playwright collections or scripts with dynamic actions, config, and test result models.",
|
|
5
5
|
"main": "dist/cli/index.js",
|
|
6
6
|
"bin": {
|
package/src/actions.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
async function assertStringContains(source, expected) {
|
|
2
|
+
const value = await source();
|
|
3
|
+
if (!value?.includes(expected)) {
|
|
4
|
+
throw new Error(`Expected "${expected}" in "${value}"`);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export const actionRegistry = {
|
|
8
|
+
goto: async (page, step) => page.goto(step.url),
|
|
9
|
+
click: async (page, step) => page.click(step.selector),
|
|
10
|
+
type: async (page, step) => page.fill(step.selector, step.value),
|
|
11
|
+
screenshot: async (page, step) => page.screenshot({ path: step.path }),
|
|
12
|
+
reload: async (page) => page.reload(),
|
|
13
|
+
waitForSelector: async (page, step) => page.waitForSelector(step.selector),
|
|
14
|
+
hover: async (page, step) => page.hover(step.selector),
|
|
15
|
+
dblclick: async (page, step) => page.dblclick(step.selector),
|
|
16
|
+
check: async (page, step) => page.check(step.selector),
|
|
17
|
+
uncheck: async (page, step) => page.uncheck(step.selector),
|
|
18
|
+
selectOption: async (page, step) => page.selectOption(step.selector, step.value),
|
|
19
|
+
press: async (page, step) => page.press(step.selector, step.key),
|
|
20
|
+
waitForTimeout: async (page, step) => page.waitForTimeout(step.ms),
|
|
21
|
+
keyboardType: async (page, step) => page.keyboard.type(step.text),
|
|
22
|
+
keyboardPress: async (page, step) => page.keyboard.press(step.key),
|
|
23
|
+
assertText: async (page, step) => assertStringContains(() => page.textContent(step.selector), step.expected),
|
|
24
|
+
assertTitle: async (page, step) => assertStringContains(() => page.title(), step.expected),
|
|
25
|
+
uploadFile: async (page, step) => page.setInputFiles(step.selector, step.files),
|
|
26
|
+
waitForResponse: async (page, step) => page.waitForResponse(step.urlPattern),
|
|
27
|
+
assertVisible: async (page, step) => {
|
|
28
|
+
const isVisible = await page.isVisible(step.selector);
|
|
29
|
+
if (!isVisible) {
|
|
30
|
+
throw new Error(`Expected element "${step.selector}" to be visible`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { getPackageInfo } from "../lib/helper";
|
|
4
|
+
import * as log4js from "log4js";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import { PlaywrightRunner } from "../runner";
|
|
7
|
+
const cliLogConfig = {
|
|
8
|
+
appenders: {
|
|
9
|
+
out: {
|
|
10
|
+
type: "stdout",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
categories: {
|
|
14
|
+
default: { appenders: ["out"], level: "INFO" },
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
var pack = getPackageInfo();
|
|
18
|
+
let version = pack.version || "version unknown";
|
|
19
|
+
const program = new Command();
|
|
20
|
+
let data;
|
|
21
|
+
program
|
|
22
|
+
.name("asm-playwright-runner")
|
|
23
|
+
.description("CLI wrapper for Playwright collections or scripts")
|
|
24
|
+
.version(version)
|
|
25
|
+
.argument("<collection>", "Flow JSON file or Playwright script")
|
|
26
|
+
.option("-b, --browser <browser>", "Browser type (chromium|firefox|webkit)", "chromium")
|
|
27
|
+
.option("-v, --verbose", "Print collection information on stdout", false)
|
|
28
|
+
.option("--chromiumPath <path>", "Custom Chromium/Chrome executable path")
|
|
29
|
+
.option("--headless", "Run browser headless (default true)", false)
|
|
30
|
+
.option("-r, --resultDir <dir>", "Result directory")
|
|
31
|
+
.option("-l, --logLevel <logLevel>", "Log level", "info")
|
|
32
|
+
.option("--extraHTTPHeaders <headers>", "Extra HTTP headers as JSON string")
|
|
33
|
+
.option("--timeout <timeout>", "Test timeout in ms")
|
|
34
|
+
.option("-rv, --returnResult", "Return the full structured test result object to the caller instead of only logging output", false)
|
|
35
|
+
.option("--sslClientCert <path>", "Client certificate (PEM)")
|
|
36
|
+
.option("--sslClientKey <path>", "Client certificate private key")
|
|
37
|
+
.option("--sslClientPassphrase <passphrase>", "Client certificate passphrase");
|
|
38
|
+
program.version(version);
|
|
39
|
+
program.parse(process.argv);
|
|
40
|
+
let log_level = program.opts().logLevel || "info";
|
|
41
|
+
log_level = log_level.toUpperCase();
|
|
42
|
+
cliLogConfig.categories.default.level =
|
|
43
|
+
["DEBUG", "TRACE", "ERROR", "FATAL"].includes(log_level) ? log_level : "INFO";
|
|
44
|
+
cliLogConfig.categories.default.level = log_level;
|
|
45
|
+
log4js.configure(cliLogConfig);
|
|
46
|
+
var logger = log4js.getLogger(pack.name);
|
|
47
|
+
logger.info("%s (%s) started", pack.name, version);
|
|
48
|
+
// now construct runner
|
|
49
|
+
const runner = new PlaywrightRunner();
|
|
50
|
+
(async () => {
|
|
51
|
+
const collection = program.args[0];
|
|
52
|
+
const options = program.opts();
|
|
53
|
+
if (!fs.existsSync(collection) || !fs.lstatSync(collection).isFile()) {
|
|
54
|
+
console.error("The specified collection file %s does not exist", collection);
|
|
55
|
+
process.exit(9);
|
|
56
|
+
}
|
|
57
|
+
await runner.init(collection, options);
|
|
58
|
+
await runner.run(collection, options);
|
|
59
|
+
await runner.close();
|
|
60
|
+
})();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import readline from "readline";
|
|
3
|
+
import unzipper from 'unzipper';
|
|
4
|
+
import path from "path";
|
|
5
|
+
export function readJsonFile(pathName) {
|
|
6
|
+
try {
|
|
7
|
+
let content = fs.readFileSync(pathName);
|
|
8
|
+
return JSON.parse(content.toString());
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
var packInfo;
|
|
15
|
+
export function getPackageInfo() {
|
|
16
|
+
if (packInfo === undefined) {
|
|
17
|
+
packInfo = readJsonFile(`${__dirname}/../../package.json`);
|
|
18
|
+
}
|
|
19
|
+
return packInfo;
|
|
20
|
+
}
|
|
21
|
+
export async function traceZipToLines(tracePath) {
|
|
22
|
+
const directory = await unzipper.Open.file(tracePath);
|
|
23
|
+
const traceEntry = directory.files.find(file => file.path.endsWith("test.trace"));
|
|
24
|
+
const events = [];
|
|
25
|
+
if (!traceEntry) {
|
|
26
|
+
return events;
|
|
27
|
+
}
|
|
28
|
+
const stream = traceEntry.stream();
|
|
29
|
+
const rl = readline.createInterface({
|
|
30
|
+
input: stream,
|
|
31
|
+
crlfDelay: Infinity,
|
|
32
|
+
});
|
|
33
|
+
for await (const line of rl) {
|
|
34
|
+
const text = line.toString().trim();
|
|
35
|
+
if (!text) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
events.push(JSON.parse(text));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.error("Invalid JSON:", text);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return events;
|
|
46
|
+
}
|
|
47
|
+
export function loadFlow(filePath) {
|
|
48
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
export function collectTraceFiles(resultDir) {
|
|
52
|
+
const traces = [];
|
|
53
|
+
const walk = (dir) => {
|
|
54
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
55
|
+
const fullPath = path.join(dir, entry);
|
|
56
|
+
const stat = fs.statSync(fullPath);
|
|
57
|
+
if (stat.isDirectory()) {
|
|
58
|
+
walk(fullPath);
|
|
59
|
+
}
|
|
60
|
+
else if (entry === "trace.zip") {
|
|
61
|
+
traces.push(fullPath);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(resultDir);
|
|
66
|
+
return traces;
|
|
67
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import unzipper from 'unzipper';
|
|
2
|
+
import archiver from 'archiver';
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import { LogLevel, ResultDir } from "../model/runnerConfig";
|
|
6
|
+
export async function getJsonFileData(tracePath, fileName) {
|
|
7
|
+
const directory = await unzipper.Open.file(tracePath);
|
|
8
|
+
const file = directory.files.find(f => f.path.endsWith(fileName));
|
|
9
|
+
if (!file) {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
const content = await file.buffer();
|
|
13
|
+
return JSON.parse(content.toString("utf-8"));
|
|
14
|
+
}
|
|
15
|
+
export async function getFileData(tracePath, fileName) {
|
|
16
|
+
const directory = await unzipper.Open.file(tracePath);
|
|
17
|
+
const traceFile = directory.files.find(f => f.path.endsWith(fileName));
|
|
18
|
+
if (!traceFile) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const content = await traceFile.buffer();
|
|
22
|
+
return content.toString('utf-8').split('\n').filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
async function extractResources(zipPath, targetImages = []) {
|
|
25
|
+
const baseDir = path.dirname(zipPath);
|
|
26
|
+
const runName = path.basename(baseDir);
|
|
27
|
+
const resultDir = path.dirname(baseDir);
|
|
28
|
+
const parentDir = resultDir === "." ? runName : resultDir;
|
|
29
|
+
const appendRunName = resultDir === "." ? [] : [runName];
|
|
30
|
+
const screenshotsDir = path.join(parentDir, ResultDir.SCREENSHOT, ...appendRunName);
|
|
31
|
+
const sourceDir = path.join(parentDir, ResultDir.SOURCE, ...appendRunName);
|
|
32
|
+
// Create folders if not exists
|
|
33
|
+
[screenshotsDir, sourceDir].forEach(dir => {
|
|
34
|
+
if (!fs.existsSync(dir)) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const directory = await unzipper.Open.file(zipPath);
|
|
39
|
+
// Copy target images
|
|
40
|
+
const imagePromises = targetImages.map(targetImage => {
|
|
41
|
+
const fileEntry = directory.files.find((file) => file.path === `resources/${targetImage}`);
|
|
42
|
+
if (!fileEntry) {
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
const outputPath = path.join(screenshotsDir, targetImage);
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
fileEntry
|
|
48
|
+
.stream()
|
|
49
|
+
.pipe(fs.createWriteStream(outputPath))
|
|
50
|
+
.on('finish', () => {
|
|
51
|
+
resolve(true);
|
|
52
|
+
})
|
|
53
|
+
.on('error', reject);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
// Copy all src@*.txt files
|
|
57
|
+
const sourcePromises = directory.files
|
|
58
|
+
.filter(file => file.path.startsWith('resources/') &&
|
|
59
|
+
/^src@.*\.txt$/.test(path.basename(file.path)))
|
|
60
|
+
.map(fileEntry => {
|
|
61
|
+
const fileName = path.basename(fileEntry.path);
|
|
62
|
+
const outputPath = path.join(sourceDir, fileName);
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
fileEntry
|
|
65
|
+
.stream()
|
|
66
|
+
.pipe(fs.createWriteStream(outputPath))
|
|
67
|
+
.on('finish', () => {
|
|
68
|
+
resolve(true);
|
|
69
|
+
})
|
|
70
|
+
.on('error', reject);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
const errorContext = path.join(baseDir, "error-context.md");
|
|
74
|
+
if (fs.existsSync(errorContext)) {
|
|
75
|
+
fs.copyFileSync(errorContext, path.join(sourceDir, "error-context.md"));
|
|
76
|
+
}
|
|
77
|
+
await Promise.all([...imagePromises, ...sourcePromises]);
|
|
78
|
+
}
|
|
79
|
+
export async function zipResources(outputZipPath, screenshotsDir, sourceDir) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const output = fs.createWriteStream(outputZipPath);
|
|
82
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
83
|
+
output.on("close", resolve);
|
|
84
|
+
archive.on("error", reject);
|
|
85
|
+
archive.pipe(output);
|
|
86
|
+
if (fs.existsSync(screenshotsDir)) {
|
|
87
|
+
archive.directory(screenshotsDir, ResultDir.SCREENSHOT);
|
|
88
|
+
}
|
|
89
|
+
if (fs.existsSync(sourceDir)) {
|
|
90
|
+
archive.directory(sourceDir, ResultDir.SOURCE);
|
|
91
|
+
}
|
|
92
|
+
archive.finalize();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export async function setHookActions(traceRawResult) {
|
|
96
|
+
const hookModel = { actions: [], stdio: [], errors: [] };
|
|
97
|
+
const hookActions = {};
|
|
98
|
+
for (const [index, row] of traceRawResult.entries()) {
|
|
99
|
+
const entry = JSON.parse(row);
|
|
100
|
+
if (entry.type === "context-options") {
|
|
101
|
+
Object.assign(hookModel, {
|
|
102
|
+
origin: entry.origin,
|
|
103
|
+
browserName: entry.browserName,
|
|
104
|
+
playwrightVersion: entry.playwrightVersion,
|
|
105
|
+
options: entry.options,
|
|
106
|
+
platform: entry.platform,
|
|
107
|
+
wallTime: entry.wallTime,
|
|
108
|
+
startTime: entry.monotonicTime,
|
|
109
|
+
sdkLanguage: entry.sdkLanguage,
|
|
110
|
+
testIdAttributeName: entry.testIdAttributeName,
|
|
111
|
+
contextId: entry.contextId,
|
|
112
|
+
testTimeout: entry.testTimeout,
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
else if (entry.type === "stdout" || entry.type === "stderr") {
|
|
117
|
+
hookModel?.stdio?.push(entry);
|
|
118
|
+
}
|
|
119
|
+
else if (entry.type === "before") {
|
|
120
|
+
const action = {
|
|
121
|
+
type: "action",
|
|
122
|
+
callId: entry.callId,
|
|
123
|
+
stepId: entry.stepId,
|
|
124
|
+
parentId: entry.parentId,
|
|
125
|
+
startTime: entry.startTime,
|
|
126
|
+
class: entry.class,
|
|
127
|
+
method: entry.method,
|
|
128
|
+
title: entry.title,
|
|
129
|
+
params: entry.params,
|
|
130
|
+
stack: entry.stack,
|
|
131
|
+
log: [],
|
|
132
|
+
};
|
|
133
|
+
hookActions[entry.callId] = action;
|
|
134
|
+
hookModel.actions.push(action);
|
|
135
|
+
}
|
|
136
|
+
else if (entry.type === "after" && hookActions[entry.callId]) {
|
|
137
|
+
Object.assign(hookActions[entry.callId], {
|
|
138
|
+
endTime: entry.endTime,
|
|
139
|
+
annotations: entry.annotations,
|
|
140
|
+
error: entry.error,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
else if (entry.type == "error") {
|
|
144
|
+
hookModel.errors?.push(entry);
|
|
145
|
+
}
|
|
146
|
+
if (index === (traceRawResult.length - 1) && entry.type === "after") {
|
|
147
|
+
hookModel.endTime = entry.endTime;
|
|
148
|
+
if (hookModel.endTime != null && hookModel.startTime) {
|
|
149
|
+
hookModel.duration = Number((hookModel.endTime - hookModel.startTime).toFixed(3));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return hookModel;
|
|
154
|
+
}
|
|
155
|
+
export async function setActions(traceRawResult, traceModel, tracePath, stackData) {
|
|
156
|
+
let beforeFlag = false;
|
|
157
|
+
let action = null;
|
|
158
|
+
let targetImages = [];
|
|
159
|
+
for (const row of traceRawResult) {
|
|
160
|
+
const entry = JSON.parse(row);
|
|
161
|
+
if (entry.type === 'context-options') {
|
|
162
|
+
traceModel.origin = entry.origin;
|
|
163
|
+
traceModel.browserName = entry.browserName;
|
|
164
|
+
traceModel.playwrightVersion = entry.playwrightVersion;
|
|
165
|
+
traceModel.options = entry.options;
|
|
166
|
+
traceModel.platform = entry.platform;
|
|
167
|
+
traceModel.wallTime = entry.wallTime;
|
|
168
|
+
traceModel.startTime = entry.monotonicTime;
|
|
169
|
+
traceModel.sdkLanguage = entry.sdkLanguage;
|
|
170
|
+
traceModel.testIdAttributeName = entry.testIdAttributeName;
|
|
171
|
+
traceModel.contextId = entry.contextId;
|
|
172
|
+
}
|
|
173
|
+
else if (entry.type == "event" || entry.type == "console") {
|
|
174
|
+
traceModel.events?.push(entry);
|
|
175
|
+
}
|
|
176
|
+
else if (entry.type === "stdout" || entry.type === "stderr") {
|
|
177
|
+
traceModel?.stdio?.push(entry);
|
|
178
|
+
}
|
|
179
|
+
else if (entry.type == "screencast-frame") {
|
|
180
|
+
let page = traceModel.pages?.find(p => p.pageId === entry.pageId);
|
|
181
|
+
if (!page) {
|
|
182
|
+
page = {
|
|
183
|
+
pageId: entry.pageId,
|
|
184
|
+
screencastFrames: [],
|
|
185
|
+
};
|
|
186
|
+
traceModel.pages?.push(page);
|
|
187
|
+
}
|
|
188
|
+
targetImages.push(entry.sha1);
|
|
189
|
+
page.screencastFrames.push(entry);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
if (entry.type == "before") {
|
|
193
|
+
action = {
|
|
194
|
+
callId: entry.callId,
|
|
195
|
+
class: entry.class,
|
|
196
|
+
method: entry.method,
|
|
197
|
+
startTime: entry.startTime,
|
|
198
|
+
type: "action",
|
|
199
|
+
params: entry.params,
|
|
200
|
+
pageId: entry.pageId,
|
|
201
|
+
log: [],
|
|
202
|
+
stack: stackData[entry.callId]
|
|
203
|
+
};
|
|
204
|
+
if (entry.class == "Frame") {
|
|
205
|
+
action.beforeSnapshot = entry.beforeSnapshot;
|
|
206
|
+
}
|
|
207
|
+
beforeFlag = true;
|
|
208
|
+
}
|
|
209
|
+
if (beforeFlag && action != null && action.callId == entry.callId) {
|
|
210
|
+
if (entry.type == "log") {
|
|
211
|
+
action.log?.push({
|
|
212
|
+
time: entry.time,
|
|
213
|
+
message: entry.message
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (entry.type == "after") {
|
|
217
|
+
beforeFlag = false;
|
|
218
|
+
action.endTime = entry.endTime;
|
|
219
|
+
action.result = entry.result;
|
|
220
|
+
action.error = entry.error;
|
|
221
|
+
action.afterSnapshot = entry.afterSnapshot;
|
|
222
|
+
traceModel.actions?.push(action);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
traceModel.endTime = action?.endTime;
|
|
228
|
+
if (traceModel.endTime != null && traceModel.startTime) {
|
|
229
|
+
traceModel.duration = Number((traceModel.endTime - traceModel.startTime).toFixed(3));
|
|
230
|
+
}
|
|
231
|
+
await extractResources(tracePath, targetImages);
|
|
232
|
+
}
|
|
233
|
+
export async function setResources(resourceRawResult, traceModel) {
|
|
234
|
+
for (const row of resourceRawResult) {
|
|
235
|
+
const entry = JSON.parse(row);
|
|
236
|
+
traceModel.resources?.push(entry.snapshot);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export async function prepareStackData(stacksRawResult) {
|
|
240
|
+
const preparedData = {};
|
|
241
|
+
const files = stacksRawResult.files || [];
|
|
242
|
+
const stacks = stacksRawResult.stacks || [];
|
|
243
|
+
for (const stack of stacks) {
|
|
244
|
+
const [callId, frames] = stack;
|
|
245
|
+
preparedData[`call@${callId}`] = frames.map(([fileIndex, line, column, fn]) => ({
|
|
246
|
+
file: files[fileIndex] || `source-file-${fileIndex}`,
|
|
247
|
+
fileIndex: `source-file-${fileIndex}`,
|
|
248
|
+
line,
|
|
249
|
+
column,
|
|
250
|
+
functionName: fn || ""
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
return preparedData;
|
|
254
|
+
}
|
|
255
|
+
export async function prepareTraceModel(tracePath, logLevel) {
|
|
256
|
+
const traceRawResult = await getFileData(tracePath, "trace.trace");
|
|
257
|
+
const resourceRawResult = await getFileData(tracePath, "trace.network");
|
|
258
|
+
const stacksRawResult = await getJsonFileData(tracePath, "trace.stacks");
|
|
259
|
+
const traceModel = {
|
|
260
|
+
actions: [],
|
|
261
|
+
events: [],
|
|
262
|
+
resources: [],
|
|
263
|
+
errors: [],
|
|
264
|
+
pages: [],
|
|
265
|
+
stdio: []
|
|
266
|
+
};
|
|
267
|
+
if (logLevel == LogLevel.DEBUG) {
|
|
268
|
+
fs.writeFileSync(path.join(path.dirname(tracePath), "trace-raw-data.json"), JSON.stringify(traceRawResult, null, 2), "utf-8");
|
|
269
|
+
}
|
|
270
|
+
const stackData = await prepareStackData(stacksRawResult);
|
|
271
|
+
await setActions(traceRawResult, traceModel, tracePath, stackData);
|
|
272
|
+
await setResources(resourceRawResult, traceModel);
|
|
273
|
+
const testHookResult = await getFileData(tracePath, "test.trace");
|
|
274
|
+
if (testHookResult && testHookResult.length > 0) {
|
|
275
|
+
if (logLevel == LogLevel.DEBUG) {
|
|
276
|
+
//raw data to validate data from trace
|
|
277
|
+
fs.writeFileSync(path.join(path.dirname(tracePath), "test-trace-raw-data.json"), JSON.stringify(testHookResult, null, 2), "utf-8");
|
|
278
|
+
}
|
|
279
|
+
const hookModel = await setHookActions(testHookResult);
|
|
280
|
+
return [hookModel, traceModel];
|
|
281
|
+
}
|
|
282
|
+
return [traceModel];
|
|
283
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export var BrowserType;
|
|
2
|
+
(function (BrowserType) {
|
|
3
|
+
BrowserType["CHROMIUM"] = "chromium";
|
|
4
|
+
BrowserType["FIREFOX"] = "firefox";
|
|
5
|
+
BrowserType["WEBKIT"] = "webkit";
|
|
6
|
+
})(BrowserType || (BrowserType = {}));
|
|
7
|
+
export var LogLevel;
|
|
8
|
+
(function (LogLevel) {
|
|
9
|
+
LogLevel["INFO"] = "info";
|
|
10
|
+
LogLevel["DEBUG"] = "debug";
|
|
11
|
+
LogLevel["ERROR"] = "error";
|
|
12
|
+
})(LogLevel || (LogLevel = {}));
|
|
13
|
+
export var ScriptType;
|
|
14
|
+
(function (ScriptType) {
|
|
15
|
+
ScriptType["JSON"] = "json";
|
|
16
|
+
ScriptType["PLAYWRIGHT"] = "playwright";
|
|
17
|
+
ScriptType["PLAYWRIGHT_TEST"] = "playwright/test";
|
|
18
|
+
})(ScriptType || (ScriptType = {}));
|
|
19
|
+
export var ResultDir;
|
|
20
|
+
(function (ResultDir) {
|
|
21
|
+
ResultDir["BASE_DIR"] = "result";
|
|
22
|
+
ResultDir["SCREENSHOT"] = "screenshots";
|
|
23
|
+
ResultDir["SOURCE"] = "source";
|
|
24
|
+
})(ResultDir || (ResultDir = {}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
4
|
+
import { actionRegistry } from "./actions";
|
|
5
|
+
import { BrowserType, ResultDir, ScriptType } from "./model/runnerConfig";
|
|
6
|
+
import { prepareTraceModel, zipResources } from "./lib/parser";
|
|
7
|
+
import { getLogger } from "log4js";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { collectTraceFiles, loadFlow } from "./lib/helper";
|
|
10
|
+
const RESULT_FILE = "trace-model.json";
|
|
11
|
+
export class PlaywrightRunner {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.browser = null;
|
|
14
|
+
this.context = null;
|
|
15
|
+
this.page = null;
|
|
16
|
+
this.options = null;
|
|
17
|
+
this.logger = getLogger(PlaywrightRunner.name);
|
|
18
|
+
}
|
|
19
|
+
async init(collectionPath, options) {
|
|
20
|
+
this.logger.info("Initializing PlaywrightRunner...");
|
|
21
|
+
// Normalize resultDir
|
|
22
|
+
if (!options.resultDir || options.resultDir.trim() === "") {
|
|
23
|
+
options.resultDir = ResultDir.BASE_DIR;
|
|
24
|
+
this.logger.debug("No resultDir specified. Defaulting to 'results'.");
|
|
25
|
+
}
|
|
26
|
+
this.options = options;
|
|
27
|
+
// Detect script type
|
|
28
|
+
this.options.scriptType = await this.detectScriptType(collectionPath);
|
|
29
|
+
this.options.isValidScript = this.options.scriptType !== undefined;
|
|
30
|
+
if (!this.options.isValidScript) {
|
|
31
|
+
this.logger.warn("Script is not valid.");
|
|
32
|
+
process.exit(9);
|
|
33
|
+
}
|
|
34
|
+
// Skip browser setup for Playwright Test files
|
|
35
|
+
if (this.options.scriptType === ScriptType.PLAYWRIGHT_TEST) {
|
|
36
|
+
this.logger.info("Playwright Test detected. Skipping manual browser setup.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Browser setup
|
|
40
|
+
const browserType = options.browser || BrowserType.CHROMIUM;
|
|
41
|
+
const headless = options.headless;
|
|
42
|
+
this.logger.debug(`Browser type: ${browserType}, headless: ${headless}`);
|
|
43
|
+
this.browser = await (browserType === BrowserType.FIREFOX ? firefox :
|
|
44
|
+
browserType === BrowserType.WEBKIT ? webkit :
|
|
45
|
+
chromium).launch({
|
|
46
|
+
headless,
|
|
47
|
+
...(options.chromiumPath && { executablePath: options.chromiumPath })
|
|
48
|
+
});
|
|
49
|
+
this.logger.info("Browser launched successfully.");
|
|
50
|
+
let headers = {};
|
|
51
|
+
if (options.extraHTTPHeaders) {
|
|
52
|
+
try {
|
|
53
|
+
headers = JSON.parse(options.extraHTTPHeaders);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
this.logger.warn("Invalid JSON for extraHTTPHeaders, ignoring:", options.extraHTTPHeaders);
|
|
57
|
+
headers = {}; // fallback to empty headers
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
this.context = await this.browser.newContext({
|
|
61
|
+
ignoreHTTPSErrors: true,
|
|
62
|
+
...(options.sslClientCert && {
|
|
63
|
+
clientCert: {
|
|
64
|
+
cert: options.sslClientCert,
|
|
65
|
+
key: options.sslClientKey,
|
|
66
|
+
passphrase: options.sslClientPassphrase
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
extraHTTPHeaders: headers,
|
|
70
|
+
});
|
|
71
|
+
this.logger.info("Browser context created.");
|
|
72
|
+
await this.context.tracing.start({ snapshots: true, screenshots: true, sources: true });
|
|
73
|
+
this.logger.info("Tracing started (snapshots, screenshots, sources enabled).");
|
|
74
|
+
this.page = await this.context.newPage();
|
|
75
|
+
// @ts-ignore
|
|
76
|
+
if (this.options?.scriptType != ScriptType.PLAYWRIGHT_TEST && this.options.timeout != null) {
|
|
77
|
+
this.page.setDefaultTimeout(Number(this.options.timeout));
|
|
78
|
+
}
|
|
79
|
+
this.logger.info("New page created.");
|
|
80
|
+
}
|
|
81
|
+
async run(collectionPath, options) {
|
|
82
|
+
this.logger.info(`Running collection: ${collectionPath}`);
|
|
83
|
+
// Default trace path(s)
|
|
84
|
+
let tracePaths = [
|
|
85
|
+
options.resultDir
|
|
86
|
+
? path.join(options.resultDir, "trace.zip")
|
|
87
|
+
: "trace.zip"
|
|
88
|
+
];
|
|
89
|
+
switch (this.options?.scriptType) {
|
|
90
|
+
case ScriptType.JSON: {
|
|
91
|
+
const flow = loadFlow(collectionPath);
|
|
92
|
+
this.logger.info(`Loaded flow: ${flow.name} with ${flow.steps.length} steps`);
|
|
93
|
+
for (const [index, step] of flow.steps.entries()) {
|
|
94
|
+
const handler = actionRegistry[step.action];
|
|
95
|
+
this.logger.debug(`Executing step ${index}: action=${step.action}`);
|
|
96
|
+
try {
|
|
97
|
+
if (!handler)
|
|
98
|
+
this.logger.error(`Unknown action: ${step.action}`);
|
|
99
|
+
if (!this.page) {
|
|
100
|
+
this.logger.error("Page not initialized. Did you call init()?");
|
|
101
|
+
process.exit(9);
|
|
102
|
+
}
|
|
103
|
+
await handler(this.page, step);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
this.logger.error(`Step ${index} failed: ${err.message}`, err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case ScriptType.PLAYWRIGHT: {
|
|
112
|
+
this.logger.info("Detected plain Playwright script with default export.");
|
|
113
|
+
const mod = await import(path.resolve(collectionPath));
|
|
114
|
+
const fn = mod.default;
|
|
115
|
+
if (typeof fn !== "function") {
|
|
116
|
+
throw new Error("Script does not export a default function");
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await fn(this.page);
|
|
120
|
+
this.logger.info("Script execution finished.");
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
this.logger.error(`Script execution failed: ${err.message}`);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case ScriptType.PLAYWRIGHT_TEST: {
|
|
128
|
+
this.logger.info("Detected Playwright Test file. Running via CLI...");
|
|
129
|
+
const args = [
|
|
130
|
+
"playwright",
|
|
131
|
+
"test",
|
|
132
|
+
collectionPath,
|
|
133
|
+
"--browser", options.browser || BrowserType.CHROMIUM,
|
|
134
|
+
"--output", options.resultDir || "results",
|
|
135
|
+
"--trace", "on"
|
|
136
|
+
];
|
|
137
|
+
if (options.timeout != null)
|
|
138
|
+
args.push("--timeout", options.timeout);
|
|
139
|
+
if (!options.headless)
|
|
140
|
+
args.push("--headed");
|
|
141
|
+
// if (options.verbose) args.push("--verbose");
|
|
142
|
+
this.logger.debug(`Spawning Playwright CLI: npx ${args.join(" ")}`);
|
|
143
|
+
await new Promise((resolve) => {
|
|
144
|
+
const proc = spawn("npx", args, { stdio: "inherit", shell: true });
|
|
145
|
+
proc.on("close", (code) => {
|
|
146
|
+
if (code === 0) {
|
|
147
|
+
this.logger.info("Playwright Test finished successfully.");
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
this.logger.error(`Playwright Test exited with code ${code}`);
|
|
151
|
+
}
|
|
152
|
+
tracePaths = collectTraceFiles(options.resultDir || "results");
|
|
153
|
+
resolve();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
default:
|
|
159
|
+
this.logger.error("Unsupported or invalid script type.");
|
|
160
|
+
process.exit(9);
|
|
161
|
+
}
|
|
162
|
+
this.logger.info("Stopping tracing...");
|
|
163
|
+
await this.traceProcess(tracePaths);
|
|
164
|
+
}
|
|
165
|
+
async traceProcess(tracePaths) {
|
|
166
|
+
if (!tracePaths?.length) {
|
|
167
|
+
this.logger.warn("No trace paths provided.");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Only stop tracing if this.context is active and we’re handling a live run
|
|
171
|
+
if (this.context && this.options?.scriptType != ScriptType.PLAYWRIGHT_TEST) {
|
|
172
|
+
await this.context.tracing.stop({
|
|
173
|
+
path: tracePaths[0]
|
|
174
|
+
});
|
|
175
|
+
this.logger.info(`Trace saved to ${tracePaths}`);
|
|
176
|
+
}
|
|
177
|
+
const models = {};
|
|
178
|
+
for (const tracePath of tracePaths) {
|
|
179
|
+
try {
|
|
180
|
+
this.logger.info(`Parsing trace: ${tracePath}`);
|
|
181
|
+
const traceModel = await prepareTraceModel(tracePath, this.options?.logLevel);
|
|
182
|
+
const runName = path.basename(path.dirname(tracePath));
|
|
183
|
+
models[runName] = traceModel;
|
|
184
|
+
const individualPath = path.join(path.dirname(tracePath), RESULT_FILE);
|
|
185
|
+
fs.writeFileSync(individualPath, JSON.stringify({ "__self": traceModel }, null, 2), "utf-8");
|
|
186
|
+
this.logger.debug(`Individual trace model written to ${individualPath}`);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
this.logger.error(`Failed to process trace ${tracePath}: ${err.message}`, err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Write combined model if more than one trace
|
|
193
|
+
if (this.options?.scriptType == ScriptType.PLAYWRIGHT_TEST) {
|
|
194
|
+
const combinedPath = this.options?.resultDir
|
|
195
|
+
? path.join(this.options.resultDir, RESULT_FILE)
|
|
196
|
+
: RESULT_FILE;
|
|
197
|
+
fs.writeFileSync(combinedPath, JSON.stringify(models, null, 2), "utf-8");
|
|
198
|
+
this.logger.info(`Combined trace model written to ${combinedPath}`);
|
|
199
|
+
}
|
|
200
|
+
const resourceDir = this.options?.resultDir ?? ResultDir.BASE_DIR;
|
|
201
|
+
const finalZipPath = path.join(resourceDir, `artifacts.zip`);
|
|
202
|
+
await zipResources(finalZipPath, path.join(resourceDir, ResultDir.SCREENSHOT), path.join(resourceDir, ResultDir.SOURCE));
|
|
203
|
+
if (this.options?.returnResult) {
|
|
204
|
+
console.log(JSON.stringify(models));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async detectScriptType(collectionPath) {
|
|
208
|
+
const fileContent = fs.readFileSync(collectionPath, "utf-8");
|
|
209
|
+
if (fileContent.includes("export default")) {
|
|
210
|
+
return ScriptType.PLAYWRIGHT;
|
|
211
|
+
}
|
|
212
|
+
if (fileContent.includes("test(")) {
|
|
213
|
+
return ScriptType.PLAYWRIGHT_TEST;
|
|
214
|
+
}
|
|
215
|
+
if (collectionPath.endsWith(".json")) {
|
|
216
|
+
return ScriptType.JSON;
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
async close() {
|
|
221
|
+
this.logger.info("Closing browser...");
|
|
222
|
+
await this.browser?.close();
|
|
223
|
+
this.logger.info("Browser closed.");
|
|
224
|
+
}
|
|
225
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
"target": "ES2020",
|
|
4
|
-
"module": "
|
|
5
|
-
"moduleResolution": "node",
|
|
6
|
-
"esModuleInterop": true,
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"rootDir": "./src", // source folder
|
|
10
|
-
"strict": true, // enables strict type checking
|
|
11
|
-
"skipLibCheck": true // avoids type issues in node_modules
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true
|
|
12
9
|
},
|
|
13
10
|
"include": ["src"],
|
|
14
11
|
"exclude": ["node_modules", "dist", "results"]
|