@playwright-orchestrator/pg 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Command } from '@commander-js/extra-typings';
2
- import { CreateArgs } from './create-args.js';
3
- import { PostgreSQLAdapter } from './postgresql-adapter.js';
4
- export declare function factory(args: CreateArgs): Promise<PostgreSQLAdapter>;
2
+ import { Container } from 'inversify';
3
+ import type { CreateArgs } from './create-args.js';
4
+ export declare function register(container: Container, options: CreateArgs): Promise<void>;
5
5
  export declare function createOptions(command: Command): void;
6
6
  export declare const description = "PostgreSQL storage adapter";
package/dist/index.js CHANGED
@@ -1,17 +1,28 @@
1
1
  import { Option } from '@commander-js/extra-typings';
2
- import { PostgreSQLAdapter } from './postgresql-adapter.js';
2
+ import { SYMBOLS } from '@playwright-orchestrator/core';
3
+ import { PostgreSQLAdapter } from './pg-adapter.js';
4
+ import { PgShardHandler } from './pg-shard-handler.js';
5
+ import { PgInitializer } from './pg-initializer.js';
6
+ import { PgTestRunCreator } from './pg-test-run-creator.js';
7
+ import { PgPool } from './pg-pool.js';
3
8
  import { readFile } from 'node:fs/promises';
4
- export async function factory(args) {
5
- if (args.sslCa) {
6
- args.sslCa = await readFile(args.sslCa);
9
+ import { PG_CONFIG, PG_POOL } from './symbols.js';
10
+ export async function register(container, options) {
11
+ if (options.sslCa) {
12
+ options.sslCa = await readFile(options.sslCa);
7
13
  }
8
- if (args.sslCert) {
9
- args.sslCert = await readFile(args.sslCert);
14
+ if (options.sslCert) {
15
+ options.sslCert = await readFile(options.sslCert);
10
16
  }
11
- if (args.sslKey) {
12
- args.sslKey = await readFile(args.sslKey);
17
+ if (options.sslKey) {
18
+ options.sslKey = await readFile(options.sslKey);
13
19
  }
14
- return new PostgreSQLAdapter(args);
20
+ container.bind(PG_CONFIG).toConstantValue(options);
21
+ container.bind(PG_POOL).to(PgPool).inSingletonScope();
22
+ container.bind(SYMBOLS.Adapter).to(PostgreSQLAdapter).inSingletonScope();
23
+ container.bind(SYMBOLS.ShardHandler).to(PgShardHandler).inSingletonScope();
24
+ container.bind(SYMBOLS.Initializer).to(PgInitializer).inSingletonScope();
25
+ container.bind(SYMBOLS.TestRunCreator).to(PgTestRunCreator).inSingletonScope();
15
26
  }
16
27
  export function createOptions(command) {
17
28
  command
@@ -0,0 +1,14 @@
1
+ import { BaseAdapter, TestRunReport, SaveTestResultParams } from '@playwright-orchestrator/core';
2
+ import type { CreateArgs } from './create-args.js';
3
+ import { PgPool } from './pg-pool.js';
4
+ export declare class PostgreSQLAdapter extends BaseAdapter {
5
+ private readonly configTable;
6
+ private readonly testsTable;
7
+ private readonly testInfoTable;
8
+ private readonly testInfoHistoryTable;
9
+ private readonly pool;
10
+ constructor({ tableNamePrefix }: CreateArgs, pgPool: PgPool);
11
+ getReportData(runId: string): Promise<TestRunReport>;
12
+ getTestEma(testId: string): Promise<number>;
13
+ saveTestResult({ runId, test, item, historyWindow, newEma }: SaveTestResultParams): Promise<void>;
14
+ }
@@ -0,0 +1,135 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ import { BaseAdapter, TestStatus, } from '@playwright-orchestrator/core';
14
+ import { injectable, inject } from 'inversify';
15
+ import { PgPool } from './pg-pool.js';
16
+ import pg from 'pg';
17
+ import { PG_CONFIG, PG_POOL } from './symbols.js';
18
+ let PostgreSQLAdapter = class PostgreSQLAdapter extends BaseAdapter {
19
+ configTable;
20
+ testsTable;
21
+ testInfoTable;
22
+ testInfoHistoryTable;
23
+ pool;
24
+ constructor({ tableNamePrefix }, pgPool) {
25
+ super();
26
+ this.pool = pgPool.pool;
27
+ this.configTable = pg.escapeIdentifier(`${tableNamePrefix}_test_runs`);
28
+ this.testsTable = pg.escapeIdentifier(`${tableNamePrefix}_tests`);
29
+ this.testInfoTable = pg.escapeIdentifier(`${tableNamePrefix}_test_info`);
30
+ this.testInfoHistoryTable = pg.escapeIdentifier(`${tableNamePrefix}_test_info_history`);
31
+ }
32
+ async getReportData(runId) {
33
+ const { rows: [{ config }], } = await this.pool.query({
34
+ text: `SELECT config FROM ${this.configTable} WHERE id = $1`,
35
+ values: [runId],
36
+ });
37
+ if (!config)
38
+ throw new Error(`Run ${runId} not found`);
39
+ const { rows } = await this.pool.query({
40
+ text: `SELECT * FROM ${this.testsTable} WHERE run_id = $1`,
41
+ values: [runId],
42
+ });
43
+ return {
44
+ runId,
45
+ config,
46
+ tests: rows.map(({ file, projects, line, character, report }) => ({
47
+ averageDuration: report?.ema ?? 0,
48
+ duration: report?.duration ?? 0,
49
+ status: report?.status ?? TestStatus.Ready,
50
+ fails: report?.fails ?? 0,
51
+ file,
52
+ position: `${line}:${character}`,
53
+ projects,
54
+ title: report?.title,
55
+ lastSuccessfulRunTimestamp: report?.lastSuccessfulRun,
56
+ })),
57
+ };
58
+ }
59
+ async getTestEma(testId) {
60
+ const { rows: [testInfo], } = await this.pool.query({
61
+ text: `SELECT ema FROM ${this.testInfoTable} WHERE name = $1`,
62
+ values: [testId],
63
+ });
64
+ return testInfo?.ema ?? 0;
65
+ }
66
+ async saveTestResult({ runId, test, item, historyWindow, newEma }) {
67
+ const client = await this.pool.connect();
68
+ try {
69
+ await client.query('BEGIN');
70
+ const { rows: [{ id }], } = await client.query({
71
+ text: `UPDATE ${this.testInfoTable} SET ema = $1 WHERE name = $2 RETURNING id`,
72
+ values: [newEma, test.testId],
73
+ });
74
+ await client.query({
75
+ text: `INSERT INTO ${this.testInfoHistoryTable} (status, duration, updated, test_info_id) VALUES ($1, $2, NOW(), $3)`,
76
+ values: [item.status, item.duration, id],
77
+ });
78
+ await client.query({
79
+ text: `DELETE FROM ${this.testInfoHistoryTable}
80
+ WHERE id IN (
81
+ SELECT id
82
+ FROM ${this.testInfoHistoryTable}
83
+ WHERE test_info_id = $1
84
+ ORDER BY updated
85
+ LIMIT GREATEST(0, (SELECT COUNT(*) FROM ${this.testInfoHistoryTable} WHERE test_info_id = $1) - $2)
86
+ )`,
87
+ values: [id, historyWindow],
88
+ });
89
+ const { rows } = await client.query({
90
+ text: `SELECT status, duration, EXTRACT(EPOCH FROM updated) * 1000 AS updated
91
+ FROM ${this.testInfoHistoryTable} WHERE test_info_id = $1 ORDER BY updated`,
92
+ values: [id],
93
+ });
94
+ const history = rows.map(({ status, duration, updated }) => ({
95
+ status: +status,
96
+ duration,
97
+ updated: +updated,
98
+ }));
99
+ const report = this.buildReport(test, item, newEma, history);
100
+ await client.query({
101
+ text: `UPDATE ${this.testsTable}
102
+ SET status = $1, updated = NOW(), report = $2
103
+ WHERE run_id = $3 AND order_num = $4`,
104
+ values: [
105
+ report.status,
106
+ {
107
+ title: report.title,
108
+ status: report.status,
109
+ duration: report.duration,
110
+ ema: report.averageDuration,
111
+ fails: report.fails,
112
+ lastSuccessfulRun: report.lastSuccessfulRunTimestamp,
113
+ },
114
+ runId,
115
+ test.order,
116
+ ],
117
+ });
118
+ await client.query('COMMIT');
119
+ }
120
+ catch (e) {
121
+ await client.query('ROLLBACK');
122
+ throw e;
123
+ }
124
+ finally {
125
+ client.release();
126
+ }
127
+ }
128
+ };
129
+ PostgreSQLAdapter = __decorate([
130
+ injectable(),
131
+ __param(0, inject(PG_CONFIG)),
132
+ __param(1, inject(PG_POOL)),
133
+ __metadata("design:paramtypes", [Object, PgPool])
134
+ ], PostgreSQLAdapter);
135
+ export { PostgreSQLAdapter };
@@ -0,0 +1,9 @@
1
+ import type { Initializer } from '@playwright-orchestrator/core';
2
+ import type { CreateArgs } from './create-args.js';
3
+ import { PgPool } from './pg-pool.js';
4
+ export declare class PgInitializer implements Initializer {
5
+ private readonly config;
6
+ private readonly pgPool;
7
+ constructor(config: CreateArgs, pgPool: PgPool);
8
+ initialize(): Promise<void>;
9
+ }
@@ -0,0 +1,97 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ import { injectable, inject } from 'inversify';
14
+ import { PgPool } from './pg-pool.js';
15
+ import pg from 'pg';
16
+ import { PG_CONFIG, PG_POOL } from './symbols.js';
17
+ let PgInitializer = class PgInitializer {
18
+ config;
19
+ pgPool;
20
+ constructor(config, pgPool) {
21
+ this.config = config;
22
+ this.pgPool = pgPool;
23
+ }
24
+ async initialize() {
25
+ const { tableNamePrefix } = this.config;
26
+ const configTable = pg.escapeIdentifier(`${tableNamePrefix}_test_runs`);
27
+ const testsTable = pg.escapeIdentifier(`${tableNamePrefix}_tests`);
28
+ const testInfoTable = pg.escapeIdentifier(`${tableNamePrefix}_test_info`);
29
+ const testInfoHistoryTable = pg.escapeIdentifier(`${tableNamePrefix}_test_info_history`);
30
+ await this.pgPool.pool.query(`CREATE TABLE IF NOT EXISTS ${configTable} (
31
+ id UUID PRIMARY KEY,
32
+ status INT NOT NULL,
33
+ updated TIMESTAMP NOT NULL DEFAULT NOW(),
34
+ config JSONB NOT NULL
35
+ );
36
+ CREATE TABLE IF NOT EXISTS ${testsTable} (
37
+ run_id UUID NOT NULL,
38
+ order_num INT NOT NULL,
39
+ status INT NOT NULL DEFAULT 0,
40
+ test_id TEXT NOT NULL,
41
+ file TEXT NOT NULL,
42
+ line INT NOT NULL,
43
+ character INT NOT NULL,
44
+ projects JSONB NOT NULL,
45
+ timeout INT NOT NULL,
46
+ ema FLOAT NOT NULL,
47
+ updated TIMESTAMP NOT NULL DEFAULT NOW(),
48
+ report JSONB,
49
+ children JSONB,
50
+ PRIMARY KEY (run_id, order_num),
51
+ FOREIGN KEY (run_id) REFERENCES ${configTable}(id)
52
+ );
53
+ ALTER TABLE ${testsTable} ADD COLUMN IF NOT EXISTS children JSONB;
54
+ ALTER TABLE ${testsTable} ADD COLUMN IF NOT EXISTS ema FLOAT NOT NULL DEFAULT 0;
55
+ ALTER TABLE ${testsTable} ALTER COLUMN ema DROP DEFAULT;
56
+ ALTER TABLE ${testsTable} ADD COLUMN IF NOT EXISTS test_id TEXT NOT NULL DEFAULT '';
57
+ ALTER TABLE ${testsTable} ALTER COLUMN test_id DROP DEFAULT;
58
+ DO $$
59
+ BEGIN
60
+ IF NOT EXISTS (
61
+ SELECT 1
62
+ FROM information_schema.columns
63
+ WHERE table_name = ${pg.escapeLiteral(`${tableNamePrefix}_tests`)} AND column_name = 'projects'
64
+ ) THEN
65
+ ALTER TABLE ${testsTable} ADD COLUMN projects JSONB NOT NULL DEFAULT '[]';
66
+ UPDATE ${testsTable} SET projects = jsonb_build_array(project) WHERE project IS NOT NULL;
67
+ ALTER TABLE ${testsTable} DROP COLUMN IF EXISTS project;
68
+ ALTER TABLE ${testsTable} ALTER COLUMN projects DROP DEFAULT;
69
+ END IF;
70
+ END $$;
71
+ UPDATE ${testsTable} SET projects = '[]' WHERE projects IS NULL;
72
+ CREATE INDEX IF NOT EXISTS status_idx ON ${testsTable}(status);
73
+ CREATE TABLE IF NOT EXISTS ${testInfoTable} (
74
+ id SERIAL PRIMARY KEY,
75
+ name TEXT NOT NULL,
76
+ ema FLOAT NOT NULL DEFAULT 0,
77
+ created TIMESTAMP NOT NULL DEFAULT NOW()
78
+ );
79
+ CREATE INDEX IF NOT EXISTS name_idx ON ${testInfoTable} USING HASH (name);
80
+ CREATE TABLE IF NOT EXISTS ${testInfoHistoryTable} (
81
+ id SERIAL PRIMARY KEY,
82
+ duration FLOAT NOT NULL,
83
+ status INT NOT NULL,
84
+ updated TIMESTAMP NOT NULL DEFAULT NOW(),
85
+ test_info_id INT NOT NULL,
86
+ FOREIGN KEY (test_info_id) REFERENCES ${testInfoTable}(id)
87
+ );
88
+ CREATE INDEX IF NOT EXISTS test_info_id_idx ON ${testInfoHistoryTable}(test_info_id);`);
89
+ }
90
+ };
91
+ PgInitializer = __decorate([
92
+ injectable(),
93
+ __param(0, inject(PG_CONFIG)),
94
+ __param(1, inject(PG_POOL)),
95
+ __metadata("design:paramtypes", [Object, PgPool])
96
+ ], PgInitializer);
97
+ export { PgInitializer };
@@ -0,0 +1,7 @@
1
+ import type { CreateArgs } from './create-args.js';
2
+ import pg from 'pg';
3
+ export declare class PgPool {
4
+ readonly pool: pg.Pool;
5
+ constructor({ connectionString, sslCa, sslCert, sslKey }: CreateArgs);
6
+ dispose(): Promise<void>;
7
+ }
@@ -0,0 +1,46 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ import { injectable, inject, preDestroy } from 'inversify';
14
+ import pg from 'pg';
15
+ import { PG_CONFIG } from './symbols.js';
16
+ let PgPool = class PgPool {
17
+ pool;
18
+ constructor({ connectionString, sslCa, sslCert, sslKey }) {
19
+ const config = { connectionString };
20
+ config.ssl = sslCa || sslCert || sslKey ? {} : undefined;
21
+ if (sslCa)
22
+ config.ssl.ca = sslCa;
23
+ if (sslCert && sslKey) {
24
+ config.ssl.cert = sslCert;
25
+ config.ssl.key = sslKey;
26
+ }
27
+ this.pool = new pg.Pool(config);
28
+ }
29
+ async dispose() {
30
+ if (this.pool.ending)
31
+ return;
32
+ await this.pool.end();
33
+ }
34
+ };
35
+ __decorate([
36
+ preDestroy(),
37
+ __metadata("design:type", Function),
38
+ __metadata("design:paramtypes", []),
39
+ __metadata("design:returntype", Promise)
40
+ ], PgPool.prototype, "dispose", null);
41
+ PgPool = __decorate([
42
+ injectable(),
43
+ __param(0, inject(PG_CONFIG)),
44
+ __metadata("design:paramtypes", [Object])
45
+ ], PgPool);
46
+ export { PgPool };
@@ -0,0 +1,15 @@
1
+ import type { ShardHandler } from '@playwright-orchestrator/core';
2
+ import type { TestItem, TestRunConfig } from '@playwright-orchestrator/core';
3
+ import type { CreateArgs } from './create-args.js';
4
+ import { PgPool } from './pg-pool.js';
5
+ export declare class PgShardHandler implements ShardHandler {
6
+ private readonly configTable;
7
+ private readonly testsTable;
8
+ private readonly pool;
9
+ constructor({ tableNamePrefix }: CreateArgs, pgPool: PgPool);
10
+ getNextTest(runId: string, _config: TestRunConfig): Promise<TestItem | undefined>;
11
+ getNextTestByProject(runId: string, project: string): Promise<TestItem | undefined>;
12
+ private claimNextTest;
13
+ startShard(runId: string): Promise<TestRunConfig>;
14
+ finishShard(runId: string): Promise<void>;
15
+ }
@@ -0,0 +1,134 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ import { injectable, inject } from 'inversify';
14
+ import { RunStatus, TestStatus } from '@playwright-orchestrator/core';
15
+ import { PgPool } from './pg-pool.js';
16
+ import pg from 'pg';
17
+ import { PG_CONFIG, PG_POOL } from './symbols.js';
18
+ let PgShardHandler = class PgShardHandler {
19
+ configTable;
20
+ testsTable;
21
+ pool;
22
+ constructor({ tableNamePrefix }, pgPool) {
23
+ this.pool = pgPool.pool;
24
+ this.configTable = pg.escapeIdentifier(`${tableNamePrefix}_test_runs`);
25
+ this.testsTable = pg.escapeIdentifier(`${tableNamePrefix}_tests`);
26
+ }
27
+ async getNextTest(runId, _config) {
28
+ return this.claimNextTest(runId);
29
+ }
30
+ async getNextTestByProject(runId, project) {
31
+ return this.claimNextTest(runId, project);
32
+ }
33
+ async claimNextTest(runId, project) {
34
+ const projectFilter = project ? `AND projects @> to_jsonb(ARRAY[$4]::text[])` : '';
35
+ const values = project
36
+ ? [runId, TestStatus.Ready, TestStatus.Ongoing, project]
37
+ : [runId, TestStatus.Ready, TestStatus.Ongoing];
38
+ const client = await this.pool.connect();
39
+ try {
40
+ await client.query('BEGIN');
41
+ const result = await client.query({
42
+ text: `WITH next_test AS (
43
+ SELECT order_num FROM ${this.testsTable}
44
+ WHERE run_id = $1 AND status = $2 ${projectFilter}
45
+ ORDER BY order_num
46
+ LIMIT 1
47
+ FOR UPDATE SKIP LOCKED
48
+ )
49
+ UPDATE ${this.testsTable} t
50
+ SET status = $3, updated = NOW()
51
+ FROM next_test
52
+ WHERE t.run_id = $1 AND t.order_num = next_test.order_num
53
+ RETURNING *`,
54
+ values,
55
+ });
56
+ await client.query('COMMIT');
57
+ if (result.rowCount === 0)
58
+ return undefined;
59
+ const { file, line, character, projects, timeout, ema, order_num, children, test_id } = result.rows[0];
60
+ return {
61
+ file,
62
+ position: `${line}:${character}`,
63
+ projects,
64
+ timeout,
65
+ ema,
66
+ order: order_num,
67
+ children,
68
+ testId: test_id,
69
+ };
70
+ }
71
+ catch (e) {
72
+ await client.query('ROLLBACK');
73
+ throw e;
74
+ }
75
+ finally {
76
+ client.release();
77
+ }
78
+ }
79
+ async startShard(runId) {
80
+ const client = await this.pool.connect();
81
+ try {
82
+ await client.query('BEGIN');
83
+ let result = await client.query({
84
+ text: `SELECT * FROM ${this.configTable} WHERE id = $1 FOR UPDATE`,
85
+ values: [runId],
86
+ });
87
+ if (result.rowCount === 0) {
88
+ throw new Error(`Run ${runId} not found`);
89
+ }
90
+ const { updated: updatedBefore, status: statusBefore } = result.rows[0];
91
+ if (statusBefore === RunStatus.Created || statusBefore === RunStatus.Finished) {
92
+ await client.query({
93
+ text: `UPDATE ${this.testsTable}
94
+ SET updated = NOW(), status = $3
95
+ WHERE run_id = $1 AND status = $2 AND updated <= $4;`,
96
+ values: [runId, TestStatus.Failed, TestStatus.Ready, updatedBefore],
97
+ });
98
+ result = await client.query({
99
+ text: `UPDATE ${this.configTable}
100
+ SET status = (CASE
101
+ WHEN status = $2 THEN ${RunStatus.Run}
102
+ ELSE ${RunStatus.RepeatRun}
103
+ END),
104
+ updated = NOW()
105
+ WHERE id = $1
106
+ RETURNING *;`,
107
+ values: [runId, RunStatus.Created],
108
+ });
109
+ }
110
+ await client.query('COMMIT');
111
+ return result.rows[0].config;
112
+ }
113
+ catch (e) {
114
+ await client.query('ROLLBACK');
115
+ throw e;
116
+ }
117
+ finally {
118
+ client.release();
119
+ }
120
+ }
121
+ async finishShard(runId) {
122
+ await this.pool.query({
123
+ text: `UPDATE ${this.configTable} SET status = $1, updated = NOW() WHERE id = $2`,
124
+ values: [RunStatus.Finished, runId],
125
+ });
126
+ }
127
+ };
128
+ PgShardHandler = __decorate([
129
+ injectable(),
130
+ __param(0, inject(PG_CONFIG)),
131
+ __param(1, inject(PG_POOL)),
132
+ __metadata("design:paramtypes", [Object, PgPool])
133
+ ], PgShardHandler);
134
+ export { PgShardHandler };
@@ -0,0 +1,14 @@
1
+ import { BaseTestRunCreator } from '@playwright-orchestrator/core';
2
+ import type { TestItem, TestRun, TestSortItem } from '@playwright-orchestrator/core';
3
+ import type { CreateArgs } from './create-args.js';
4
+ import { PgPool } from './pg-pool.js';
5
+ export declare class PgTestRunCreator extends BaseTestRunCreator {
6
+ private readonly configTable;
7
+ private readonly testsTable;
8
+ private readonly testInfoTable;
9
+ private readonly testInfoHistoryTable;
10
+ private readonly pool;
11
+ constructor({ tableNamePrefix }: CreateArgs, pgPool: PgPool);
12
+ loadTestInfos(tests: TestItem[]): Promise<Map<string, TestSortItem>>;
13
+ saveRunData(runId: string, run: TestRun, tests: TestItem[]): Promise<void>;
14
+ }
@@ -0,0 +1,127 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ import { injectable, inject, injectFromBase } from 'inversify';
14
+ import { BaseTestRunCreator, RunStatus, TestStatus } from '@playwright-orchestrator/core';
15
+ import { PgPool } from './pg-pool.js';
16
+ import pg from 'pg';
17
+ import { PG_CONFIG, PG_POOL } from './symbols.js';
18
+ let PgTestRunCreator = class PgTestRunCreator extends BaseTestRunCreator {
19
+ configTable;
20
+ testsTable;
21
+ testInfoTable;
22
+ testInfoHistoryTable;
23
+ pool;
24
+ constructor({ tableNamePrefix }, pgPool) {
25
+ super();
26
+ this.pool = pgPool.pool;
27
+ this.configTable = pg.escapeIdentifier(`${tableNamePrefix}_test_runs`);
28
+ this.testsTable = pg.escapeIdentifier(`${tableNamePrefix}_tests`);
29
+ this.testInfoTable = pg.escapeIdentifier(`${tableNamePrefix}_test_info`);
30
+ this.testInfoHistoryTable = pg.escapeIdentifier(`${tableNamePrefix}_test_info_history`);
31
+ }
32
+ async loadTestInfos(tests) {
33
+ const results = await this.pool.query({
34
+ text: `
35
+ WITH test_names AS (
36
+ SELECT UNNEST($1::TEXT[]) AS name
37
+ ),
38
+ existing_tests AS (
39
+ SELECT id, name, ema, created FROM ${this.testInfoTable}
40
+ WHERE name IN (SELECT name FROM test_names)
41
+ ),
42
+ inserted_tests AS (
43
+ INSERT INTO ${this.testInfoTable} (name)
44
+ SELECT name FROM test_names
45
+ WHERE NOT EXISTS (
46
+ SELECT 1 FROM ${this.testInfoTable} WHERE name = test_names.name
47
+ )
48
+ RETURNING id, name, ema, created
49
+ ),
50
+ combined_tests AS (
51
+ SELECT * FROM existing_tests
52
+ UNION ALL
53
+ SELECT * FROM inserted_tests
54
+ )
55
+ SELECT
56
+ t.id,
57
+ t.name,
58
+ t.ema,
59
+ t.created,
60
+ COUNT(CASE WHEN h.status = ${TestStatus.Failed} THEN 1 END) as fails
61
+ FROM combined_tests t
62
+ LEFT JOIN ${this.testInfoHistoryTable} h ON h.test_info_id = t.id
63
+ GROUP BY t.id, t.name, t.ema, t.created`,
64
+ values: [tests.map((t) => t.testId)],
65
+ });
66
+ const testInfo = new Map();
67
+ for (const { name, ema, fails } of results.rows) {
68
+ testInfo.set(name, { ema, fails: +fails });
69
+ }
70
+ return testInfo;
71
+ }
72
+ async saveRunData(runId, run, tests) {
73
+ const client = await this.pool.connect();
74
+ try {
75
+ await client.query('BEGIN');
76
+ await client.query({
77
+ text: `INSERT INTO ${this.configTable} (id, status, config) VALUES ($1, $2, $3)`,
78
+ values: [runId, RunStatus.Created, JSON.stringify(run.config)],
79
+ });
80
+ if (tests.length > 0) {
81
+ const fields = ['order_num', 'file', 'line', 'character', 'projects', 'timeout', 'ema', 'children', 'test_id'];
82
+ await client.query({
83
+ text: `INSERT INTO ${this.testsTable} (run_id, ${fields.join(', ')}) VALUES ${tests
84
+ .map((_, i) => {
85
+ const len = fields.length;
86
+ const values = fields.map((_, j) => `$${i * len + j + 2}`).join(', ');
87
+ return `($1, ${values})`;
88
+ })
89
+ .join(', ')}`,
90
+ values: [
91
+ runId,
92
+ ...tests.flatMap(({ position, order, file, projects, timeout, ema, children, testId }) => {
93
+ const [line, character] = position.split(':');
94
+ return [
95
+ order,
96
+ file,
97
+ line,
98
+ character,
99
+ JSON.stringify(projects),
100
+ timeout,
101
+ ema,
102
+ children != null ? JSON.stringify(children) : null,
103
+ testId,
104
+ ];
105
+ }),
106
+ ],
107
+ });
108
+ }
109
+ await client.query('COMMIT');
110
+ }
111
+ catch (e) {
112
+ await client.query('ROLLBACK');
113
+ throw e;
114
+ }
115
+ finally {
116
+ client.release();
117
+ }
118
+ }
119
+ };
120
+ PgTestRunCreator = __decorate([
121
+ injectable(),
122
+ injectFromBase({ extendProperties: true, extendConstructorArguments: false }),
123
+ __param(0, inject(PG_CONFIG)),
124
+ __param(1, inject(PG_POOL)),
125
+ __metadata("design:paramtypes", [Object, PgPool])
126
+ ], PgTestRunCreator);
127
+ export { PgTestRunCreator };
@@ -0,0 +1,2 @@
1
+ export declare const PG_CONFIG: unique symbol;
2
+ export declare const PG_POOL: unique symbol;
@@ -0,0 +1,2 @@
1
+ export const PG_CONFIG = Symbol.for('PgConfig');
2
+ export const PG_POOL = Symbol.for('PgPool');
package/package.json CHANGED
@@ -1,34 +1,33 @@
1
1
  {
2
- "name": "@playwright-orchestrator/pg",
3
- "version": "1.2.1",
4
- "keywords": [],
5
- "author": "Rostyslav Kudrevatykh",
6
- "license": "Apache-2.0",
7
- "description": "Playwright orchestrator PostgreSQL plugin",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/rostmanrk/playwright-orchestrator.git"
11
- },
12
- "files": [
13
- "dist"
14
- ],
15
- "dependencies": {
16
- "@commander-js/extra-typings": "^13.0.0",
17
- "@playwright-orchestrator/core": "^1.1.0",
18
- "commander": "^13.0.0",
19
- "pg": "^8.13.1"
20
- },
21
- "devDependencies": {
22
- "@types/pg": "^8.11.0"
23
- },
24
- "main": "dist/index.js",
25
- "type": "module",
26
- "exports": {
27
- ".": {
28
- "default": "./dist/index.js"
29
- }
30
- },
31
- "scripts": {
32
- "prepare": "cp ../../LICENSE.md ./"
2
+ "name": "@playwright-orchestrator/pg",
3
+ "version": "1.3.1",
4
+ "keywords": [],
5
+ "author": "Rostyslav Kudrevatykh",
6
+ "license": "Apache-2.0",
7
+ "description": "Playwright orchestrator PostgreSQL plugin",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rostmanrk/playwright-orchestrator.git"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@commander-js/extra-typings": "^13.0.0",
17
+ "commander": "^13.0.0",
18
+ "inversify": "^8.0.0-beta.0",
19
+ "pg": "^8.13.1",
20
+ "@playwright-orchestrator/core": "1.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@types/pg": "^8.11.0"
24
+ },
25
+ "main": "dist/index.js",
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "default": "./dist/index.js"
33
30
  }
34
- }
31
+ },
32
+ "scripts": {}
33
+ }
@@ -1,23 +0,0 @@
1
- import { TestItem, Adapter, TestRunConfig, ResultTestParams, SaveTestRunParams } from '@playwright-orchestrator/core';
2
- import { CreateArgs } from './create-args.js';
3
- import { TestRunReport } from '../../core/dist/types/reporter.js';
4
- export declare class PostgreSQLAdapter extends Adapter {
5
- private readonly configTable;
6
- private readonly testsTable;
7
- private readonly testInfoTable;
8
- private readonly testInfoHistoryTable;
9
- private readonly pool;
10
- constructor({ connectionString, tableNamePrefix, sslCa, sslCert, sslKey }: CreateArgs);
11
- getNextTest(runId: string, config: TestRunConfig): Promise<TestItem | undefined>;
12
- finishTest(params: ResultTestParams): Promise<void>;
13
- failTest(params: ResultTestParams): Promise<void>;
14
- saveTestRun({ runId, args, historyWindow, testRun }: SaveTestRunParams): Promise<void>;
15
- initialize(): Promise<void>;
16
- startShard(runId: string): Promise<TestRunConfig>;
17
- finishShard(runId: string): Promise<void>;
18
- dispose(): Promise<void>;
19
- getReportData(runId: string): Promise<TestRunReport>;
20
- private updateTestWithResults;
21
- private loadTestInfos;
22
- private mapConfig;
23
- }
@@ -1,327 +0,0 @@
1
- import { Adapter, RunStatus, TestStatus, } from '@playwright-orchestrator/core';
2
- import pg from 'pg';
3
- export class PostgreSQLAdapter extends Adapter {
4
- configTable;
5
- testsTable;
6
- testInfoTable;
7
- testInfoHistoryTable;
8
- pool;
9
- constructor({ connectionString, tableNamePrefix, sslCa, sslCert, sslKey }) {
10
- super();
11
- const config = { connectionString };
12
- config.ssl = sslCa || sslCert || sslKey ? {} : undefined;
13
- if (sslCa) {
14
- config.ssl.ca = sslCa;
15
- }
16
- if (sslCert && sslKey) {
17
- config.ssl.cert = sslCert;
18
- config.ssl.key = sslKey;
19
- }
20
- this.pool = new pg.Pool(config);
21
- this.configTable = pg.escapeIdentifier(`${tableNamePrefix}_test_runs`);
22
- this.testsTable = pg.escapeIdentifier(`${tableNamePrefix}_tests`);
23
- this.testInfoTable = pg.escapeIdentifier(`${tableNamePrefix}_test_info`);
24
- this.testInfoHistoryTable = pg.escapeIdentifier(`${tableNamePrefix}_test_info_history`);
25
- }
26
- async getNextTest(runId, config) {
27
- const client = await this.pool.connect();
28
- try {
29
- await client.query('BEGIN');
30
- const result = await client.query({
31
- text: `WITH next_test AS (
32
- SELECT order_num FROM ${this.testsTable}
33
- WHERE run_id = $1 AND status = $2
34
- ORDER BY order_num
35
- LIMIT 1
36
- FOR UPDATE SKIP LOCKED
37
- )
38
- UPDATE ${this.testsTable} t
39
- SET status = $3, updated = NOW()
40
- FROM next_test
41
- WHERE t.run_id = $1 AND t.order_num = next_test.order_num
42
- RETURNING *`,
43
- values: [runId, TestStatus.Ready, TestStatus.Ongoing],
44
- });
45
- await client.query('COMMIT');
46
- if (result.rowCount === 0)
47
- return undefined;
48
- const { file, line, character, project, timeout, order_num } = result.rows[0];
49
- return { file, position: `${line}:${character}`, project, timeout, order: order_num };
50
- }
51
- catch (e) {
52
- await this.pool.query('ROLLBACK');
53
- }
54
- finally {
55
- client.release();
56
- }
57
- }
58
- async finishTest(params) {
59
- await this.updateTestWithResults(TestStatus.Passed, params);
60
- }
61
- async failTest(params) {
62
- await this.updateTestWithResults(TestStatus.Failed, params);
63
- }
64
- async saveTestRun({ runId, args, historyWindow, testRun }) {
65
- let tests = this.transformTestRunToItems(testRun.testRun);
66
- const testInfos = await this.loadTestInfos(tests);
67
- tests = this.sortTests(tests, testInfos, { historyWindow });
68
- await this.pool.query({
69
- text: `INSERT INTO ${this.configTable} (id, status, config) VALUES ($1, $2, $3)`,
70
- values: [runId, RunStatus.Created, JSON.stringify({ ...testRun.config, args, historyWindow })],
71
- });
72
- const fields = ['order_num', 'file', 'line', 'character', 'project', 'timeout'];
73
- await this.pool.query({
74
- text: `INSERT INTO ${this.testsTable} (run_id, ${fields.join(', ')}) VALUES ${tests
75
- .map((_, i) => {
76
- const len = fields.length;
77
- const values = fields.map((_, j) => `$${i * len + j + 2}`).join(', ');
78
- return `($1, ${values})`;
79
- })
80
- .join(', ')}`,
81
- values: [
82
- runId,
83
- ...tests.flatMap(({ position, order, file, project, timeout }) => {
84
- const [line, character] = position.split(':');
85
- return [order, file, line, character, project, timeout];
86
- }),
87
- ],
88
- });
89
- }
90
- async initialize() {
91
- await this.pool.query(`CREATE TABLE IF NOT EXISTS ${this.configTable} (
92
- id UUID PRIMARY KEY,
93
- status INT NOT NULL,
94
- updated TIMESTAMP NOT NULL DEFAULT NOW(),
95
- config JSONB NOT NULL
96
- );
97
- CREATE TABLE IF NOT EXISTS ${this.testsTable} (
98
- run_id UUID NOT NULL,
99
- order_num INT NOT NULL,
100
- status INT NOT NULL DEFAULT 0,
101
- file TEXT NOT NULL,
102
- line INT NOT NULL,
103
- character INT NOT NULL,
104
- project TEXT NOT NULL,
105
- timeout INT NOT NULL,
106
- updated TIMESTAMP NOT NULL DEFAULT NOW(),
107
- report JSONB,
108
- PRIMARY KEY (run_id, order_num),
109
- FOREIGN KEY (run_id) REFERENCES ${this.configTable}(id)
110
- );
111
- CREATE INDEX IF NOT EXISTS status_idx ON ${this.testsTable}(status);
112
- CREATE TABLE IF NOT EXISTS ${this.testInfoTable} (
113
- id SERIAL PRIMARY KEY,
114
- name TEXT NOT NULL,
115
- ema FLOAT NOT NULL DEFAULT 0,
116
- created TIMESTAMP NOT NULL DEFAULT NOW()
117
- );
118
- CREATE INDEX IF NOT EXISTS name_idx ON ${this.testInfoTable} USING HASH (name);
119
- CREATE TABLE IF NOT EXISTS ${this.testInfoHistoryTable} (
120
- id SERIAL PRIMARY KEY,
121
- duration FLOAT NOT NULL,
122
- status INT NOT NULL,
123
- updated TIMESTAMP NOT NULL DEFAULT NOW(),
124
- test_info_id INT NOT NULL,
125
- FOREIGN KEY (test_info_id) REFERENCES ${this.testInfoTable}(id)
126
- );
127
- CREATE INDEX IF NOT EXISTS test_info_id_idx ON ${this.testInfoHistoryTable}(test_info_id);`);
128
- }
129
- async startShard(runId) {
130
- const client = await this.pool.connect();
131
- try {
132
- await client.query('BEGIN');
133
- let result = await client.query({
134
- text: `
135
- SELECT *
136
- FROM ${this.configTable}
137
- WHERE id = $1
138
- FOR UPDATE`,
139
- values: [runId],
140
- });
141
- if (result.rowCount === 0) {
142
- throw new Error(`Run ${runId} not found`);
143
- }
144
- const { updated: updatedBefore, status: statusBefore } = result.rows[0];
145
- if (statusBefore === RunStatus.Created || statusBefore === RunStatus.Finished) {
146
- await client.query({
147
- text: `
148
- UPDATE ${this.testsTable}
149
- SET updated = NOW(), status = $3
150
- WHERE run_id = $1 AND status = $2 AND updated <= $4;`,
151
- values: [runId, TestStatus.Failed, TestStatus.Ready, updatedBefore],
152
- });
153
- // using str interpolation for case statement to avoid casting ints to strings
154
- result = await client.query({
155
- text: `
156
- UPDATE ${this.configTable}
157
- SET status = (CASE
158
- WHEN status = $2 THEN ${RunStatus.Run}
159
- ELSE ${RunStatus.RepeatRun}
160
- END),
161
- updated = NOW()
162
- WHERE id = $1
163
- RETURNING *;`,
164
- values: [runId, RunStatus.Created],
165
- });
166
- }
167
- await client.query('COMMIT');
168
- return this.mapConfig(result.rows[0]);
169
- }
170
- catch (e) {
171
- await client.query('ROLLBACK');
172
- throw e;
173
- }
174
- finally {
175
- client.release();
176
- }
177
- }
178
- async finishShard(runId) {
179
- // set 'updated' field to current time as test run exhausted all tests
180
- // update 'updated' field until last shard set correct finish time
181
- await this.pool.query({
182
- text: `UPDATE ${this.configTable}
183
- SET status = $1,
184
- updated = NOW()
185
- WHERE id = $2`,
186
- values: [RunStatus.Finished, runId],
187
- });
188
- }
189
- async dispose() {
190
- if (this.pool.ending)
191
- return;
192
- await this.pool.end();
193
- }
194
- async getReportData(runId) {
195
- const { rows: [dbConfig], } = await this.pool.query({
196
- text: `SELECT * FROM ${this.configTable} WHERE id = $1`,
197
- values: [runId],
198
- });
199
- if (!dbConfig)
200
- throw new Error(`Run ${runId} not found`);
201
- const { rows } = await this.pool.query({
202
- text: `SELECT * FROM ${this.testsTable} WHERE run_id = $1`,
203
- values: [runId],
204
- });
205
- return {
206
- runId,
207
- config: this.mapConfig(dbConfig),
208
- tests: rows.map(({ file, project, line, character, report }) => ({
209
- averageDuration: 0,
210
- duration: report?.duration ?? 0,
211
- status: report?.status ?? TestStatus.Ready,
212
- fails: report?.fails ?? 0,
213
- file,
214
- position: `${line}:${character}`,
215
- project,
216
- title: report?.title,
217
- lastSuccessfulRunTimestamp: report?.lastSuccessfulRun,
218
- })),
219
- };
220
- }
221
- async updateTestWithResults(status, { runId, test, config, testResult }) {
222
- const testId = this.getTestId({ ...test, ...testResult });
223
- const { rows: [testInfo], } = await this.pool.query({
224
- text: `SELECT
225
- id,
226
- ema,
227
- (
228
- SELECT COUNT(*) FROM ${this.testInfoHistoryTable}
229
- WHERE status = ${TestStatus.Failed} AND test_info_id = info.id
230
- ) AS fails,
231
- (
232
- SELECT updated FROM ${this.testInfoHistoryTable}
233
- WHERE status = ${TestStatus.Passed} AND test_info_id = info.id
234
- ORDER BY updated DESC LIMIT 1
235
- ) AS last_successful_run
236
- FROM ${this.testInfoTable} info
237
- WHERE name = $1`,
238
- values: [testId],
239
- });
240
- const report = {
241
- title: testResult.title,
242
- status,
243
- duration: testResult.duration,
244
- ema: testInfo.ema,
245
- fails: +testInfo.fails,
246
- lastSuccessfulRun: testInfo.last_successful_run?.getTime?.(),
247
- };
248
- const newEma = this.calculateEMA(testResult.duration, testInfo.ema, config.historyWindow);
249
- await this.pool.query({
250
- text: `UPDATE ${this.testsTable}
251
- SET
252
- status = $1,
253
- updated = NOW(),
254
- report = $2
255
- WHERE run_id = $3 AND order_num = $4;`,
256
- values: [status, report, runId, test.order],
257
- });
258
- await this.pool.query({
259
- text: `UPDATE ${this.testInfoTable} SET ema = $1 WHERE id = $2;`,
260
- values: [newEma, testInfo.id],
261
- });
262
- await this.pool.query({
263
- text: `INSERT INTO ${this.testInfoHistoryTable} (status, duration, updated, test_info_id)
264
- VALUES ($1, $2, NOW(), $3);`,
265
- values: [status, testResult.duration, testInfo.id],
266
- });
267
- await this.pool.query({
268
- text: `DELETE FROM ${this.testInfoHistoryTable}
269
- WHERE id IN (
270
- SELECT id
271
- FROM ${this.testInfoHistoryTable}
272
- WHERE test_info_id = $1
273
- ORDER BY updated
274
- LIMIT 10
275
- OFFSET 10
276
- )`,
277
- values: [testInfo.id],
278
- });
279
- }
280
- async loadTestInfos(tests) {
281
- const results = await this.pool.query({
282
- text: `
283
- WITH test_names AS (
284
- SELECT UNNEST($1::TEXT[]) AS name
285
- ),
286
- existing_tests AS (
287
- SELECT id, name, ema, created FROM ${this.testInfoTable}
288
- WHERE name IN (SELECT name FROM test_names)
289
- ),
290
- inserted_tests AS (
291
- INSERT INTO ${this.testInfoTable} (name)
292
- SELECT name FROM test_names
293
- WHERE NOT EXISTS (
294
- SELECT 1 FROM ${this.testInfoTable} WHERE name = test_names.name
295
- )
296
- RETURNING id, name, ema, created
297
- ),
298
- combined_tests AS (
299
- SELECT * FROM existing_tests
300
- UNION ALL
301
- SELECT * FROM inserted_tests
302
- )
303
- SELECT
304
- t.id,
305
- t.name,
306
- t.ema,
307
- t.created,
308
- COUNT(CASE WHEN h.status = ${TestStatus.Failed} THEN 1 END) as fails
309
- FROM combined_tests t
310
- LEFT JOIN ${this.testInfoHistoryTable} h ON h.test_info_id = t.id
311
- GROUP BY t.id, t.name, t.ema, t.created`,
312
- values: [tests.map((t) => t.testId)],
313
- });
314
- const testInfo = new Map();
315
- for (const { name, ema, fails } of results.rows) {
316
- testInfo.set(name, { ema, fails: +fails });
317
- }
318
- return testInfo;
319
- }
320
- mapConfig(dbConfig) {
321
- return {
322
- ...dbConfig.config,
323
- updated: dbConfig.updated.getTime(),
324
- status: dbConfig.status,
325
- };
326
- }
327
- }