@dionlarson/playwright-orchestrator-core 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +190 -0
- package/README.md +279 -0
- package/dist/adapter.d.ts +20 -0
- package/dist/adapter.js +65 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2 -0
- package/dist/commands/create-report.d.ts +2 -0
- package/dist/commands/create-report.js +18 -0
- package/dist/commands/create.d.ts +2 -0
- package/dist/commands/create.js +26 -0
- package/dist/commands/error-handler.d.ts +1 -0
- package/dist/commands/error-handler.js +28 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +14 -0
- package/dist/commands/program.d.ts +2 -0
- package/dist/commands/program.js +17 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +18 -0
- package/dist/helpers/plugin.d.ts +13 -0
- package/dist/helpers/plugin.js +23 -0
- package/dist/helpers/reporter-tools.d.ts +2 -0
- package/dist/helpers/reporter-tools.js +21 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/playwright-tools/annotations.cjs +8 -0
- package/dist/playwright-tools/annotations.d.cts +3 -0
- package/dist/playwright-tools/run-builder.d.ts +15 -0
- package/dist/playwright-tools/run-builder.js +81 -0
- package/dist/playwright-tools/run-info-reporter.d.ts +4 -0
- package/dist/playwright-tools/run-info-reporter.js +7 -0
- package/dist/playwright-tools/test-ats-analyzer.d.ts +21 -0
- package/dist/playwright-tools/test-ats-analyzer.js +173 -0
- package/dist/playwright-tools/test-result-reporter.d.ts +11 -0
- package/dist/playwright-tools/test-result-reporter.js +63 -0
- package/dist/plugins-list.d.ts +1 -0
- package/dist/plugins-list.js +2 -0
- package/dist/reporters/gha-reporter.d.ts +2 -0
- package/dist/reporters/gha-reporter.js +42 -0
- package/dist/reporters/helpers.d.ts +7 -0
- package/dist/reporters/helpers.js +21 -0
- package/dist/reporters/reporter-factory.d.ts +4 -0
- package/dist/reporters/reporter-factory.js +14 -0
- package/dist/reporters/test-execution-reporter.d.ts +17 -0
- package/dist/reporters/test-execution-reporter.js +85 -0
- package/dist/test-runner.d.ts +18 -0
- package/dist/test-runner.js +111 -0
- package/dist/types/adapters.d.ts +39 -0
- package/dist/types/adapters.js +1 -0
- package/dist/types/reporter.d.ts +32 -0
- package/dist/types/reporter.js +1 -0
- package/dist/types/test-info.d.ts +42 -0
- package/dist/types/test-info.js +14 -0
- package/package.json +81 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { loadPlugins } from '../helpers/plugin.js';
|
|
2
|
+
import { withErrorHandling } from './error-handler.js';
|
|
3
|
+
import { program } from './program.js';
|
|
4
|
+
export default async () => {
|
|
5
|
+
const command = program.command('init').description('Run initialize script for selected storage type.');
|
|
6
|
+
for await (const { factory, subCommand } of loadPlugins(command)) {
|
|
7
|
+
subCommand.action(withErrorHandling(async (options) => {
|
|
8
|
+
const adapter = await factory(options);
|
|
9
|
+
await adapter.initialize();
|
|
10
|
+
await adapter.dispose();
|
|
11
|
+
console.log('Storage initialized');
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Command } from '@commander-js/extra-typings';
|
|
2
|
+
import init from './init.js';
|
|
3
|
+
import run from './run.js';
|
|
4
|
+
import create from './create.js';
|
|
5
|
+
import createReport from './create-report.js';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
export const program = new Command();
|
|
8
|
+
const package_json = JSON.parse(await readFile('node_modules/@playwright-orchestrator/core/package.json', 'utf-8'));
|
|
9
|
+
program
|
|
10
|
+
.name('playwright-orchestrator')
|
|
11
|
+
.description('CLI to orchestrate Playwright tests')
|
|
12
|
+
.version(package_json.version);
|
|
13
|
+
await init();
|
|
14
|
+
await run();
|
|
15
|
+
await create();
|
|
16
|
+
await createReport();
|
|
17
|
+
program.parse();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { loadPlugins } from '../helpers/plugin.js';
|
|
2
|
+
import { TestRunner } from '../test-runner.js';
|
|
3
|
+
import { withErrorHandling } from './error-handler.js';
|
|
4
|
+
import { program } from './program.js';
|
|
5
|
+
export default async () => {
|
|
6
|
+
const command = program.command('run').description('Start test run shard');
|
|
7
|
+
for await (const { factory, subCommand } of loadPlugins(command)) {
|
|
8
|
+
subCommand
|
|
9
|
+
.requiredOption('--run-id <string>', 'Run id generated by create command')
|
|
10
|
+
.option('-o, --output <string>', 'Output folder for blob reports. Existing content is deleted before writing the new report.', 'blob-reports')
|
|
11
|
+
.action(withErrorHandling(async (options) => {
|
|
12
|
+
const adapter = await factory(options);
|
|
13
|
+
const runner = new TestRunner(options, adapter);
|
|
14
|
+
await runner.runTests();
|
|
15
|
+
console.log('Run completed');
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from '@commander-js/extra-typings';
|
|
2
|
+
import { STORAGES } from '../plugins-list.js';
|
|
3
|
+
import { Adapter } from '../adapter.js';
|
|
4
|
+
export type StorageType = (typeof STORAGES)[number];
|
|
5
|
+
export declare function loadPluginModule(storage: string): Promise<{
|
|
6
|
+
factory: (options: any) => Promise<Adapter>;
|
|
7
|
+
createOptions: (command: Command) => void;
|
|
8
|
+
description?: string;
|
|
9
|
+
} | undefined>;
|
|
10
|
+
export declare function loadPlugins(command: Command<any>): AsyncGenerator<{
|
|
11
|
+
subCommand: Command<[], {}, {}>;
|
|
12
|
+
factory: (options: any) => Promise<Adapter>;
|
|
13
|
+
}, void, unknown>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { STORAGES } from '../plugins-list.js';
|
|
2
|
+
export async function loadPluginModule(storage) {
|
|
3
|
+
try {
|
|
4
|
+
const a = await import(`@playwright-orchestrator/${storage}`);
|
|
5
|
+
return a;
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function* loadPlugins(command) {
|
|
12
|
+
for (const storage of STORAGES) {
|
|
13
|
+
const plugin = await loadPluginModule(storage);
|
|
14
|
+
if (plugin) {
|
|
15
|
+
const subCommand = command.command(storage);
|
|
16
|
+
if (plugin.description) {
|
|
17
|
+
subCommand.description(plugin.description);
|
|
18
|
+
}
|
|
19
|
+
plugin.createOptions(subCommand);
|
|
20
|
+
yield { subCommand, factory: plugin.factory };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import child_process from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const exec = promisify(child_process.exec);
|
|
4
|
+
export async function loadRunInfo(args) {
|
|
5
|
+
const { stdout, stderr } = await exec(buildCommand(args), {
|
|
6
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
7
|
+
});
|
|
8
|
+
if (stderr) {
|
|
9
|
+
throw new Error(stderr);
|
|
10
|
+
}
|
|
11
|
+
// Extract JSON from stdout - skip any non-JSON lines (e.g., dotenvx logs)
|
|
12
|
+
const jsonMatch = stdout.match(/(\{[\s\S]*\})\s*$/);
|
|
13
|
+
if (!jsonMatch) {
|
|
14
|
+
throw new Error(`Failed to parse test run info. Output was: ${stdout}`);
|
|
15
|
+
}
|
|
16
|
+
return JSON.parse(jsonMatch[1]);
|
|
17
|
+
}
|
|
18
|
+
function buildCommand(args) {
|
|
19
|
+
// last param wins, so we need to put our reporter at the end
|
|
20
|
+
return `npx playwright test --list ${args.join(' ')} --reporter "@playwright-orchestrator/core/tests-info-reporter"`;
|
|
21
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FullConfig, Suite, TestCase } from '@playwright/test/reporter';
|
|
2
|
+
import { TestRunInfo } from '../types/test-info.js';
|
|
3
|
+
export declare class RunBuilder {
|
|
4
|
+
private readonly testRun;
|
|
5
|
+
private config;
|
|
6
|
+
parseEntry(entry: TestCase | Suite): this;
|
|
7
|
+
parseConfig(config: FullConfig): this;
|
|
8
|
+
build(): TestRunInfo;
|
|
9
|
+
private parseSuitesHelper;
|
|
10
|
+
private tryParseEntry;
|
|
11
|
+
private isSerialSuite;
|
|
12
|
+
private getSuiteTimeout;
|
|
13
|
+
private getAnnotations;
|
|
14
|
+
private getFileTests;
|
|
15
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export class RunBuilder {
|
|
2
|
+
testRun = {};
|
|
3
|
+
config = undefined;
|
|
4
|
+
parseEntry(entry) {
|
|
5
|
+
this.parseSuitesHelper(entry);
|
|
6
|
+
return this;
|
|
7
|
+
}
|
|
8
|
+
parseConfig(config) {
|
|
9
|
+
this.config = {
|
|
10
|
+
workers: config.workers,
|
|
11
|
+
configFile: config.configFile,
|
|
12
|
+
projects: config.projects.map((project) => ({
|
|
13
|
+
name: project.name,
|
|
14
|
+
outputDir: project.outputDir,
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
build() {
|
|
20
|
+
return structuredClone({
|
|
21
|
+
config: this.config,
|
|
22
|
+
testRun: this.testRun,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
parseSuitesHelper(entry) {
|
|
26
|
+
if (this.tryParseEntry(entry)) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const item of entry.entries()) {
|
|
30
|
+
this.parseSuitesHelper(item);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
tryParseEntry(entry) {
|
|
34
|
+
const [_, project, file] = entry.titlePath();
|
|
35
|
+
if (!file)
|
|
36
|
+
return false;
|
|
37
|
+
const fileTests = this.getFileTests(file);
|
|
38
|
+
const position = `${entry.location?.line}:${entry.location?.column}`;
|
|
39
|
+
// Use position:title as key to support parameterized tests at same location
|
|
40
|
+
const key = `${position}:${entry.title}`;
|
|
41
|
+
if (fileTests[key]) {
|
|
42
|
+
fileTests[key].projects.push(project);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
// Check for serial mode using Playwright's internal _parallelMode property
|
|
46
|
+
const isSerial = entry.type !== 'test' && this.isSerialSuite(entry);
|
|
47
|
+
if (entry.type === 'test' || isSerial) {
|
|
48
|
+
if (!fileTests[key]) {
|
|
49
|
+
// For serial suites, sum up all test timeouts
|
|
50
|
+
const timeout = entry.type === 'test'
|
|
51
|
+
? entry.timeout
|
|
52
|
+
: this.getSuiteTimeout(entry);
|
|
53
|
+
fileTests[key] = {
|
|
54
|
+
timeout,
|
|
55
|
+
projects: [project],
|
|
56
|
+
annotations: this.getAnnotations(entry),
|
|
57
|
+
title: entry.title,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
isSerialSuite(suite) {
|
|
65
|
+
return suite._parallelMode === 'serial';
|
|
66
|
+
}
|
|
67
|
+
getSuiteTimeout(suite) {
|
|
68
|
+
return suite.allTests().reduce((sum, test) => sum + test.timeout, 0);
|
|
69
|
+
}
|
|
70
|
+
getAnnotations(entry) {
|
|
71
|
+
if (entry.type === 'test') {
|
|
72
|
+
return entry.annotations;
|
|
73
|
+
}
|
|
74
|
+
return entry.allTests()[0]?.annotations;
|
|
75
|
+
}
|
|
76
|
+
getFileTests(file) {
|
|
77
|
+
if (!this.testRun[file])
|
|
78
|
+
this.testRun[file] = {};
|
|
79
|
+
return this.testRun[file];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Suite, TestCase } from '@playwright/test/reporter';
|
|
2
|
+
export declare class TestASTAnalyzer {
|
|
3
|
+
private readonly program;
|
|
4
|
+
private readonly typeChecker;
|
|
5
|
+
private readonly sourceFile;
|
|
6
|
+
private constructor();
|
|
7
|
+
static create(file?: string): TestASTAnalyzer | undefined;
|
|
8
|
+
suiteIsSerial(suite: Suite): boolean;
|
|
9
|
+
getTimeout(entry: TestCase | Suite): number;
|
|
10
|
+
private checkFileSerialStatement;
|
|
11
|
+
private isSerialStatement;
|
|
12
|
+
private getFunctionBodyStatements;
|
|
13
|
+
private extractCallStatementsTestFunc;
|
|
14
|
+
private extractTestCallStatements;
|
|
15
|
+
private isTestStatement;
|
|
16
|
+
private isSlowStatement;
|
|
17
|
+
private getTimeoutFromStatement;
|
|
18
|
+
private getNumberIdentifierValue;
|
|
19
|
+
private findTestTimeout;
|
|
20
|
+
private findNodeAtLocation;
|
|
21
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
const FILTER_PROPERTIES = ['setTimeout', 'slow', 'configure'];
|
|
3
|
+
export class TestASTAnalyzer {
|
|
4
|
+
program;
|
|
5
|
+
typeChecker;
|
|
6
|
+
sourceFile;
|
|
7
|
+
constructor(file) {
|
|
8
|
+
this.program = ts.createProgram([file], {
|
|
9
|
+
allowJs: true,
|
|
10
|
+
noResolve: true, // Don't resolve imports - much faster
|
|
11
|
+
});
|
|
12
|
+
this.typeChecker = this.program.getTypeChecker();
|
|
13
|
+
this.sourceFile = this.program.getSourceFile(file);
|
|
14
|
+
}
|
|
15
|
+
static create(file) {
|
|
16
|
+
if (!file)
|
|
17
|
+
return;
|
|
18
|
+
return new TestASTAnalyzer(file);
|
|
19
|
+
}
|
|
20
|
+
suiteIsSerial(suite) {
|
|
21
|
+
if (!suite.location || (suite.location.line === 0 && suite.location.column == 0)) {
|
|
22
|
+
return this.checkFileSerialStatement();
|
|
23
|
+
}
|
|
24
|
+
const suiteNode = this.findNodeAtLocation(suite.location);
|
|
25
|
+
if (!suiteNode ||
|
|
26
|
+
!ts.isPropertyAccessExpression(suiteNode) ||
|
|
27
|
+
!ts.isIdentifier(suiteNode.expression) ||
|
|
28
|
+
!ts.isCallExpression(suiteNode.parent))
|
|
29
|
+
return false;
|
|
30
|
+
const testNodeText = suiteNode.expression.text;
|
|
31
|
+
var testStatements = this.extractTestCallStatements(this.extractCallStatementsTestFunc(suiteNode.parent), testNodeText);
|
|
32
|
+
// last statement wins
|
|
33
|
+
for (let i = testStatements.length - 1; i >= 0; i--) {
|
|
34
|
+
if (this.isSerialStatement(testStatements[i]))
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
getTimeout(entry) {
|
|
40
|
+
if (entry.type === 'test')
|
|
41
|
+
return this.findTestTimeout(entry);
|
|
42
|
+
return entry.entries().reduce((timeout, entry) => this.getTimeout(entry) + timeout, 0);
|
|
43
|
+
}
|
|
44
|
+
checkFileSerialStatement() {
|
|
45
|
+
return this.sourceFile.statements
|
|
46
|
+
.filter((statement) => ts.isExpressionStatement(statement))
|
|
47
|
+
.map((statement) => statement.expression)
|
|
48
|
+
.some((statement) => this.isSerialStatement(statement));
|
|
49
|
+
}
|
|
50
|
+
isSerialStatement(statement) {
|
|
51
|
+
const expression = statement.expression;
|
|
52
|
+
if (!ts.isPropertyAccessExpression(expression) ||
|
|
53
|
+
expression.name.text !== 'configure' ||
|
|
54
|
+
!ts.isPropertyAccessExpression(expression.expression) ||
|
|
55
|
+
expression.expression.name.text !== 'describe' ||
|
|
56
|
+
statement.arguments.length === 0)
|
|
57
|
+
return false;
|
|
58
|
+
const arg = statement.arguments[0];
|
|
59
|
+
return (ts.isObjectLiteralExpression(arg) &&
|
|
60
|
+
arg.properties.some((prop) => {
|
|
61
|
+
return (prop.name?.getText() === 'mode' &&
|
|
62
|
+
ts.isPropertyAssignment(prop) &&
|
|
63
|
+
ts.isStringLiteral(prop.initializer) &&
|
|
64
|
+
prop.initializer.text === 'serial');
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
getFunctionBodyStatements(node) {
|
|
68
|
+
if (ts.isFunctionLike(node)) {
|
|
69
|
+
const body = node.body;
|
|
70
|
+
// Handle block body (FunctionDeclaration, FunctionExpression, MethodDeclaration)
|
|
71
|
+
if (ts.isBlock(body)) {
|
|
72
|
+
return [...body.statements];
|
|
73
|
+
}
|
|
74
|
+
// Handle expression body (ArrowFunction)
|
|
75
|
+
if (ts.isExpression(body)) {
|
|
76
|
+
return [ts.factory.createExpressionStatement(body)];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Handle function expressions in variable declarations
|
|
80
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
81
|
+
if (ts.isFunctionExpression(node.initializer) || ts.isArrowFunction(node.initializer)) {
|
|
82
|
+
return this.getFunctionBodyStatements(node.initializer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
extractCallStatementsTestFunc(node) {
|
|
88
|
+
return this.getFunctionBodyStatements(node.arguments.find((arg) => ts.isFunctionLike(arg)));
|
|
89
|
+
}
|
|
90
|
+
extractTestCallStatements(nodes, testNodeText) {
|
|
91
|
+
return nodes
|
|
92
|
+
.filter((statement) => ts.isExpressionStatement(statement) && this.isTestStatement(statement, testNodeText))
|
|
93
|
+
.map((statement) => statement.expression);
|
|
94
|
+
}
|
|
95
|
+
isTestStatement(statement, testNodeText) {
|
|
96
|
+
if (!ts.isCallExpression(statement.expression))
|
|
97
|
+
return false;
|
|
98
|
+
let node = statement.expression.getChildAt(0);
|
|
99
|
+
if (!ts.isPropertyAccessExpression(node) || !FILTER_PROPERTIES.includes(node.name.text))
|
|
100
|
+
return false;
|
|
101
|
+
while (!ts.isIdentifier(node) && node.getChildCount() > 0)
|
|
102
|
+
node = node.getChildAt(0);
|
|
103
|
+
return node.getText() === testNodeText;
|
|
104
|
+
}
|
|
105
|
+
isSlowStatement(statement) {
|
|
106
|
+
const expression = statement.expression;
|
|
107
|
+
const functionName = expression.getChildAt(expression.getChildCount() - 1);
|
|
108
|
+
return functionName.getText() === 'slow';
|
|
109
|
+
}
|
|
110
|
+
getTimeoutFromStatement(statement) {
|
|
111
|
+
const expression = statement.expression;
|
|
112
|
+
if (!ts.isCallExpression(statement) ||
|
|
113
|
+
statement.arguments.length === 0 ||
|
|
114
|
+
!ts.isPropertyAccessExpression(expression) ||
|
|
115
|
+
expression.name.text !== 'setTimeout')
|
|
116
|
+
return 0;
|
|
117
|
+
return this.getNumberIdentifierValue(statement.arguments[0]);
|
|
118
|
+
}
|
|
119
|
+
getNumberIdentifierValue(node) {
|
|
120
|
+
if (ts.isNumericLiteral(node))
|
|
121
|
+
return parseInt(node.text, 10);
|
|
122
|
+
if (ts.isIdentifier(node)) {
|
|
123
|
+
const symbol = this.typeChecker.getSymbolAtLocation(node);
|
|
124
|
+
if (!symbol)
|
|
125
|
+
return 0;
|
|
126
|
+
const declarations = symbol.declarations;
|
|
127
|
+
if (!declarations || declarations.length === 0)
|
|
128
|
+
return 0;
|
|
129
|
+
const declaration = declarations[0];
|
|
130
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
131
|
+
if (ts.isNumericLiteral(declaration.initializer)) {
|
|
132
|
+
return parseInt(declaration.initializer.text, 10);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
findTestTimeout(test) {
|
|
139
|
+
const { location, timeout } = test;
|
|
140
|
+
const testNode = this.findNodeAtLocation(location);
|
|
141
|
+
if (!testNode || !ts.isIdentifier(testNode))
|
|
142
|
+
return timeout;
|
|
143
|
+
const testNodeText = testNode.text;
|
|
144
|
+
if (!ts.isCallExpression(testNode.parent))
|
|
145
|
+
return timeout;
|
|
146
|
+
var testStatements = this.extractTestCallStatements(this.extractCallStatementsTestFunc(testNode.parent), testNodeText);
|
|
147
|
+
// last statement wins
|
|
148
|
+
for (let i = testStatements.length - 1; i >= 0; i--) {
|
|
149
|
+
const statement = testStatements[i];
|
|
150
|
+
if (this.isSlowStatement(statement))
|
|
151
|
+
return timeout * 3;
|
|
152
|
+
const localTimeout = this.getTimeoutFromStatement(statement);
|
|
153
|
+
if (localTimeout)
|
|
154
|
+
return localTimeout;
|
|
155
|
+
}
|
|
156
|
+
return timeout;
|
|
157
|
+
}
|
|
158
|
+
findNodeAtLocation(location) {
|
|
159
|
+
let position = ts.getPositionOfLineAndCharacter(this.sourceFile, Math.max(0, location.line - 1), // ts using 0-based index location
|
|
160
|
+
Math.max(0, location.column - 1));
|
|
161
|
+
if (position > 0)
|
|
162
|
+
position -= 1;
|
|
163
|
+
const findSmallestContainingNode = (node) => {
|
|
164
|
+
for (const child of node.getChildren()) {
|
|
165
|
+
if (child.getStart() < position && position < child.getEnd()) {
|
|
166
|
+
return findSmallestContainingNode(child);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return node;
|
|
170
|
+
};
|
|
171
|
+
return findSmallestContainingNode(this.sourceFile);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
|
|
2
|
+
export default class TestResultReporter implements Reporter {
|
|
3
|
+
private testResults;
|
|
4
|
+
private testCases;
|
|
5
|
+
private commonParent;
|
|
6
|
+
onBegin(config: FullConfig, suite: Suite): void;
|
|
7
|
+
onTestEnd(test: TestCase, result: TestResult): void;
|
|
8
|
+
onEnd(result: FullResult): Promise<{
|
|
9
|
+
status?: FullResult['status'];
|
|
10
|
+
} | undefined | void> | void;
|
|
11
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export default class TestResultReporter {
|
|
2
|
+
testResults = [];
|
|
3
|
+
testCases = [];
|
|
4
|
+
commonParent;
|
|
5
|
+
onBegin(config, suite) {
|
|
6
|
+
const tests = suite.allTests();
|
|
7
|
+
if (tests.length > 1) {
|
|
8
|
+
const path = [];
|
|
9
|
+
let current = tests[0].parent;
|
|
10
|
+
while (current) {
|
|
11
|
+
path.push(current);
|
|
12
|
+
current = current.parent;
|
|
13
|
+
}
|
|
14
|
+
let lastCommonParent = path.length - 1;
|
|
15
|
+
for (const test of tests.slice(1)) {
|
|
16
|
+
current = test.parent;
|
|
17
|
+
while (test.parent !== this.commonParent) {
|
|
18
|
+
const index = path.indexOf(current);
|
|
19
|
+
if (index !== -1 && index < lastCommonParent) {
|
|
20
|
+
lastCommonParent = index;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
current = current?.parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
this.commonParent = path[lastCommonParent];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
onTestEnd(test, result) {
|
|
30
|
+
this.testResults.push(result);
|
|
31
|
+
this.testCases.push(test);
|
|
32
|
+
}
|
|
33
|
+
onEnd(result) {
|
|
34
|
+
const { status, error, retry } = this.testResults.at(-1);
|
|
35
|
+
const duration = this.testResults.reduce((acc, { duration }) => acc + duration, 0);
|
|
36
|
+
const existingAnnotations = new Set();
|
|
37
|
+
const annotations = this.testCases
|
|
38
|
+
.flatMap((test) => test.annotations)
|
|
39
|
+
.filter(({ type, description }) => {
|
|
40
|
+
if (existingAnnotations.has(type + (description ?? '')))
|
|
41
|
+
return false;
|
|
42
|
+
existingAnnotations.add(type + (description ?? ''));
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
const { title } = this.commonParent ?? this.testCases.at(-1);
|
|
46
|
+
const output = {
|
|
47
|
+
status,
|
|
48
|
+
duration,
|
|
49
|
+
error,
|
|
50
|
+
title,
|
|
51
|
+
annotations,
|
|
52
|
+
tests: this.testResults.map(({ status, duration, error }, i) => ({
|
|
53
|
+
status,
|
|
54
|
+
duration,
|
|
55
|
+
error,
|
|
56
|
+
annotations: this.testCases[i].annotations,
|
|
57
|
+
title: this.testCases[i].title,
|
|
58
|
+
retry,
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
console.log(JSON.stringify(output));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const STORAGES: readonly ["file", "dynamo-db", "pg", "mysql", "mongo"];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as core from '@actions/core';
|
|
2
|
+
import { TestStatus } from '../types/test-info.js';
|
|
3
|
+
import { calculateTrend, formatDuration } from './helpers.js';
|
|
4
|
+
export async function ghaReporter(data) {
|
|
5
|
+
const { config, runId, tests } = data;
|
|
6
|
+
await core.summary
|
|
7
|
+
.addHeading(`🏃 Test run summary`)
|
|
8
|
+
.addDetails('Run config', buildConfigData(data))
|
|
9
|
+
.addTable([
|
|
10
|
+
[
|
|
11
|
+
{ data: '', header: true },
|
|
12
|
+
{ data: '📁 Project', header: true },
|
|
13
|
+
{ data: '📝 Title', header: true },
|
|
14
|
+
{ data: '⏱️ Duration', header: true },
|
|
15
|
+
{ data: '📊 Trend', header: true },
|
|
16
|
+
{ data: '✨ Last successful run', header: true },
|
|
17
|
+
{ data: '❌ Fails', header: true },
|
|
18
|
+
],
|
|
19
|
+
...tests.map((test) => {
|
|
20
|
+
const { percentage, trendIcon } = calculateTrend(test);
|
|
21
|
+
return [
|
|
22
|
+
test.status === TestStatus.Passed ? '✅' : '❌',
|
|
23
|
+
test.project,
|
|
24
|
+
test.title,
|
|
25
|
+
formatDuration(test.duration),
|
|
26
|
+
`${trendIcon} ${percentage}%`,
|
|
27
|
+
test.lastSuccessfulRunTimestamp
|
|
28
|
+
? new Date(test.lastSuccessfulRunTimestamp).toLocaleString()
|
|
29
|
+
: 'N/A',
|
|
30
|
+
test.fails.toString(),
|
|
31
|
+
];
|
|
32
|
+
}),
|
|
33
|
+
])
|
|
34
|
+
.write();
|
|
35
|
+
}
|
|
36
|
+
function buildConfigData({ config, runId }) {
|
|
37
|
+
return `
|
|
38
|
+
<table>
|
|
39
|
+
<tr><td>Run Id</td><td>${runId}</td></tr>
|
|
40
|
+
<tr><td>History Window</td><td>${config.historyWindow}</td></tr>
|
|
41
|
+
</table>`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function calculateTrend(test) {
|
|
2
|
+
const trend = test.averageDuration - test.duration;
|
|
3
|
+
return {
|
|
4
|
+
trend,
|
|
5
|
+
trendIcon: trend > 0 ? '📈' : '📉',
|
|
6
|
+
percentage: ((trend / test.averageDuration) * 100).toFixed(1),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function formatDuration(ms) {
|
|
10
|
+
const seconds = Math.floor((ms / 1000) % 60);
|
|
11
|
+
const minutes = Math.floor((ms / (1000 * 60)) % 60);
|
|
12
|
+
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
|
|
13
|
+
const parts = [];
|
|
14
|
+
if (hours)
|
|
15
|
+
parts.push(`${hours} ${hours === 1 ? 'h' : 'hs'}`);
|
|
16
|
+
if (minutes)
|
|
17
|
+
parts.push(`${minutes} ${minutes === 1 ? 'min' : 'mins'}`);
|
|
18
|
+
if (parts.length === 0 || seconds)
|
|
19
|
+
parts.push(`${seconds} ${seconds === 1 ? 'sec' : 'secs'}`);
|
|
20
|
+
return parts.join(', ');
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ghaReporter } from './gha-reporter.js';
|
|
2
|
+
export const REPORTERS = ['json', 'gha'];
|
|
3
|
+
export async function generateReport(data, type) {
|
|
4
|
+
switch (type) {
|
|
5
|
+
case 'json':
|
|
6
|
+
console.log(JSON.stringify(data));
|
|
7
|
+
break;
|
|
8
|
+
case 'gha':
|
|
9
|
+
await ghaReporter(data);
|
|
10
|
+
break;
|
|
11
|
+
default:
|
|
12
|
+
console.error('Unknown reporter type');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { TestItem } from '../types/adapters.js';
|
|
2
|
+
export declare class TestExecutionReporter {
|
|
3
|
+
private readonly failedTests;
|
|
4
|
+
private readonly succeedTests;
|
|
5
|
+
private readonly runningTests;
|
|
6
|
+
private readonly spinner;
|
|
7
|
+
private spinnerIndex;
|
|
8
|
+
private spinnerInterval?;
|
|
9
|
+
constructor();
|
|
10
|
+
addTest(test: TestItem, run: Promise<any>): void;
|
|
11
|
+
finishTest(test: TestItem): void;
|
|
12
|
+
failTest(test: TestItem): void;
|
|
13
|
+
private printTestResult;
|
|
14
|
+
printSummary(): void;
|
|
15
|
+
private redrawRunning;
|
|
16
|
+
private getKey;
|
|
17
|
+
}
|