@q-agent/agent 0.1.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/README.md +86 -0
- package/dist/src/api.js +131 -0
- package/dist/src/cli.js +118 -0
- package/dist/src/config.js +92 -0
- package/dist/src/ensureBrowser.js +102 -0
- package/dist/src/playwrightConfig.js +162 -0
- package/dist/src/report.js +106 -0
- package/dist/src/runner.js +396 -0
- package/dist/src/session.js +84 -0
- package/dist/test/api.test.js +146 -0
- package/dist/test/report.test.js +192 -0
- package/package.json +39 -0
- package/vendor/capture_auth.cjs +136 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local session store: captured `storageState.json` + `sessionStorage.json`
|
|
4
|
+
* per project origin, kept under the agent's config dir. These files hold
|
|
5
|
+
* the user's actual cookies/localStorage/sessionStorage and are NEVER
|
|
6
|
+
* uploaded to the server — only referenced by the local
|
|
7
|
+
* `playwright.config.ts` (storageState) and the generated `fixtures.ts`
|
|
8
|
+
* (sessionStorage replay), mirroring `project_config_service.auth_path` /
|
|
9
|
+
* `.session_path` on the server, but scoped to this machine instead of a
|
|
10
|
+
* server-side owner.
|
|
11
|
+
*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.sessionPathsForOrigin = sessionPathsForOrigin;
|
|
47
|
+
exports.hasValidSession = hasValidSession;
|
|
48
|
+
exports.hasSessionStorage = hasSessionStorage;
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const config_1 = require("./config");
|
|
52
|
+
/** Turn an origin like "https://app.example.com" into a filesystem-safe dir name. */
|
|
53
|
+
function sanitizeOrigin(origin) {
|
|
54
|
+
return origin.replace(/[^a-zA-Z0-9.-]/g, "_");
|
|
55
|
+
}
|
|
56
|
+
/** Resolve the on-disk paths for a given origin's captured session. */
|
|
57
|
+
function sessionPathsForOrigin(origin) {
|
|
58
|
+
const dir = path.join((0, config_1.configDir)(), "sessions", sanitizeOrigin(origin));
|
|
59
|
+
return {
|
|
60
|
+
dir,
|
|
61
|
+
storageStatePath: path.join(dir, "storageState.json"),
|
|
62
|
+
sessionStoragePath: path.join(dir, "sessionStorage.json"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** True if a non-empty captured `storageState.json` exists for `origin`. */
|
|
66
|
+
function hasValidSession(origin) {
|
|
67
|
+
const { storageStatePath } = sessionPathsForOrigin(origin);
|
|
68
|
+
try {
|
|
69
|
+
return fs.statSync(storageStatePath).size > 0;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** True if a non-empty captured `sessionStorage.json` exists for `origin`. */
|
|
76
|
+
function hasSessionStorage(origin) {
|
|
77
|
+
const { sessionStoragePath } = sessionPathsForOrigin(origin);
|
|
78
|
+
try {
|
|
79
|
+
return fs.statSync(sessionStoragePath).size > 0;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Mocked-fetch tests for the wire-protocol client (`src/api.ts`) — verifies
|
|
4
|
+
* the agent builds correct requests (method, auth header, body shape) for
|
|
5
|
+
* claim/results/evidence without needing a live server.
|
|
6
|
+
*/
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
41
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
42
|
+
};
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
45
|
+
const node_test_1 = require("node:test");
|
|
46
|
+
const api = __importStar(require("../src/api"));
|
|
47
|
+
const cfg = {
|
|
48
|
+
serverUrl: "http://127.0.0.1:8787",
|
|
49
|
+
deviceToken: "test-token",
|
|
50
|
+
deviceId: 1,
|
|
51
|
+
deviceName: "test-machine",
|
|
52
|
+
};
|
|
53
|
+
let calls = [];
|
|
54
|
+
const originalFetch = globalThis.fetch;
|
|
55
|
+
function mockFetch(handler) {
|
|
56
|
+
globalThis.fetch = (async (url, init) => {
|
|
57
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
58
|
+
calls.push({ url: u, init: init ?? {} });
|
|
59
|
+
return handler(u, init ?? {});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
(0, node_test_1.afterEach)(() => {
|
|
63
|
+
globalThis.fetch = originalFetch;
|
|
64
|
+
calls = [];
|
|
65
|
+
});
|
|
66
|
+
(0, node_test_1.test)("claimNextJob returns null on 204 (no queued job)", async () => {
|
|
67
|
+
mockFetch(() => new Response(null, { status: 204 }));
|
|
68
|
+
const job = await api.claimNextJob(cfg);
|
|
69
|
+
strict_1.default.equal(job, null);
|
|
70
|
+
strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/jobs/next");
|
|
71
|
+
strict_1.default.equal(calls[0].init.headers.Authorization, "Bearer test-token");
|
|
72
|
+
strict_1.default.equal(calls[0].init.method, "POST");
|
|
73
|
+
});
|
|
74
|
+
(0, node_test_1.test)("claimNextJob returns the parsed job payload on 200", async () => {
|
|
75
|
+
const payload = {
|
|
76
|
+
executionId: 42,
|
|
77
|
+
runCode: "RUN-1",
|
|
78
|
+
env: "Staging",
|
|
79
|
+
browser: "chromium",
|
|
80
|
+
workers: 2,
|
|
81
|
+
headless: true,
|
|
82
|
+
baseUrl: "https://app.example.com",
|
|
83
|
+
manualAuth: true,
|
|
84
|
+
authOrigins: ["https://app.example.com"],
|
|
85
|
+
specs: [{ filename: "1428-TC-01.spec.ts", code: "// spec" }],
|
|
86
|
+
};
|
|
87
|
+
mockFetch(() => Response.json(payload));
|
|
88
|
+
const job = await api.claimNextJob(cfg);
|
|
89
|
+
strict_1.default.deepEqual(job, payload);
|
|
90
|
+
});
|
|
91
|
+
(0, node_test_1.test)("claimNextJob throws ApiError on a non-ok, non-204 response", async () => {
|
|
92
|
+
mockFetch(() => new Response("nope", { status: 500 }));
|
|
93
|
+
await strict_1.default.rejects(() => api.claimNextJob(cfg), api.ApiError);
|
|
94
|
+
});
|
|
95
|
+
(0, node_test_1.test)("postResult sends the parsed-report shape as JSON", async () => {
|
|
96
|
+
mockFetch(() => new Response(null, { status: 200 }));
|
|
97
|
+
await api.postResult(cfg, 42, { file: "1428-TC-01.spec.ts", status: "pass", duration_ms: 900, error_message: "" });
|
|
98
|
+
strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/jobs/42/results");
|
|
99
|
+
const body = JSON.parse(calls[0].init.body);
|
|
100
|
+
strict_1.default.deepEqual(body, { file: "1428-TC-01.spec.ts", status: "pass", duration_ms: 900, error_message: "" });
|
|
101
|
+
});
|
|
102
|
+
(0, node_test_1.test)("postEvent wraps event+payload", async () => {
|
|
103
|
+
mockFetch(() => new Response(null, { status: 200 }));
|
|
104
|
+
await api.postEvent(cfg, 42, "exec.auth.waiting", { url: "https://app.example.com" });
|
|
105
|
+
const body = JSON.parse(calls[0].init.body);
|
|
106
|
+
strict_1.default.deepEqual(body, { event: "exec.auth.waiting", payload: { url: "https://app.example.com" } });
|
|
107
|
+
});
|
|
108
|
+
(0, node_test_1.test)("postEvidence sends a multipart form with the right fields", async () => {
|
|
109
|
+
const fs = await Promise.resolve().then(() => __importStar(require("node:fs")));
|
|
110
|
+
const os = await Promise.resolve().then(() => __importStar(require("node:os")));
|
|
111
|
+
const path = await Promise.resolve().then(() => __importStar(require("node:path")));
|
|
112
|
+
const tmpFile = path.join(os.tmpdir(), `qagent-test-${Date.now()}.png`);
|
|
113
|
+
fs.writeFileSync(tmpFile, "fake-png-bytes");
|
|
114
|
+
mockFetch(() => new Response(null, { status: 200 }));
|
|
115
|
+
await api.postEvidence(cfg, 42, {
|
|
116
|
+
ticketExternalId: "SUR-1428",
|
|
117
|
+
caseCode: "TC-01",
|
|
118
|
+
kind: "screenshot",
|
|
119
|
+
filePath: tmpFile,
|
|
120
|
+
filename: "shot.png",
|
|
121
|
+
});
|
|
122
|
+
fs.rmSync(tmpFile);
|
|
123
|
+
const init = calls[0].init;
|
|
124
|
+
strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/jobs/42/evidence");
|
|
125
|
+
strict_1.default.ok(init.body instanceof FormData);
|
|
126
|
+
const form = init.body;
|
|
127
|
+
strict_1.default.equal(form.get("ticket_external_id"), "SUR-1428");
|
|
128
|
+
strict_1.default.equal(form.get("case_code"), "TC-01");
|
|
129
|
+
strict_1.default.equal(form.get("kind"), "screenshot");
|
|
130
|
+
strict_1.default.ok(form.get("file") instanceof Blob);
|
|
131
|
+
});
|
|
132
|
+
(0, node_test_1.test)("postComplete sends the aggregate body", async () => {
|
|
133
|
+
mockFetch(() => new Response(null, { status: 200 }));
|
|
134
|
+
await api.postComplete(cfg, 42, { passed: 3, failed: 1, log: "tail" });
|
|
135
|
+
const body = JSON.parse(calls[0].init.body);
|
|
136
|
+
strict_1.default.deepEqual(body, { passed: 3, failed: 1, log: "tail" });
|
|
137
|
+
});
|
|
138
|
+
(0, node_test_1.test)("redeemDevice posts code+name with no auth header", async () => {
|
|
139
|
+
mockFetch(() => Response.json({ deviceToken: "abc", deviceId: 7 }));
|
|
140
|
+
const result = await api.redeemDevice("http://127.0.0.1:8787", "PAIR123", "my-laptop");
|
|
141
|
+
strict_1.default.deepEqual(result, { deviceToken: "abc", deviceId: 7 });
|
|
142
|
+
strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/devices/redeem");
|
|
143
|
+
strict_1.default.equal(calls[0].init.headers.Authorization, undefined);
|
|
144
|
+
const body = JSON.parse(calls[0].init.body);
|
|
145
|
+
strict_1.default.deepEqual(body, { code: "PAIR123", name: "my-laptop" });
|
|
146
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for the `parse_playwright_report` port (`src/report.ts`).
|
|
4
|
+
* Feeds a sample Playwright JSON-reporter report and asserts the flattened
|
|
5
|
+
* per-spec dicts match what the Python original would produce for the same
|
|
6
|
+
* input (status mapping, last-retry-wins, attachment filtering, nested
|
|
7
|
+
* suites, and the spec-filename convention).
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
14
|
+
const node_test_1 = require("node:test");
|
|
15
|
+
const report_1 = require("../src/report");
|
|
16
|
+
(0, node_test_1.test)("maps a passing spec with no attachments", () => {
|
|
17
|
+
const report = {
|
|
18
|
+
suites: [
|
|
19
|
+
{
|
|
20
|
+
file: "1428-TC-01.spec.ts",
|
|
21
|
+
specs: [
|
|
22
|
+
{
|
|
23
|
+
file: "1428-TC-01.spec.ts",
|
|
24
|
+
title: "logs in successfully",
|
|
25
|
+
tests: [
|
|
26
|
+
{
|
|
27
|
+
results: [{ status: "passed", duration: 1234, attachments: [] }],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
const out = (0, report_1.parsePlaywrightReport)(report);
|
|
36
|
+
strict_1.default.equal(out.length, 1);
|
|
37
|
+
strict_1.default.deepEqual(out[0], {
|
|
38
|
+
file: "1428-TC-01.spec.ts",
|
|
39
|
+
title: "logs in successfully",
|
|
40
|
+
status: "pass",
|
|
41
|
+
duration_ms: 1234,
|
|
42
|
+
error_message: "",
|
|
43
|
+
attachments: [],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
(0, node_test_1.test)("maps failed/timedOut/interrupted to fail and skipped to skipped", () => {
|
|
47
|
+
const makeSuite = (status) => ({
|
|
48
|
+
file: `case-${status}.spec.ts`,
|
|
49
|
+
specs: [
|
|
50
|
+
{
|
|
51
|
+
file: `case-${status}.spec.ts`,
|
|
52
|
+
title: "t",
|
|
53
|
+
tests: [{ results: [{ status, duration: 1, attachments: [] }] }],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
for (const [input, expected] of [
|
|
58
|
+
["failed", "fail"],
|
|
59
|
+
["timedOut", "fail"],
|
|
60
|
+
["interrupted", "fail"],
|
|
61
|
+
["skipped", "skipped"],
|
|
62
|
+
]) {
|
|
63
|
+
const out = (0, report_1.parsePlaywrightReport)({ suites: [makeSuite(input)] });
|
|
64
|
+
strict_1.default.equal(out[0].status, expected, `expected ${input} -> ${expected}`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
(0, node_test_1.test)("unknown status defaults to fail", () => {
|
|
68
|
+
const out = (0, report_1.parsePlaywrightReport)({
|
|
69
|
+
suites: [
|
|
70
|
+
{
|
|
71
|
+
file: "x.spec.ts",
|
|
72
|
+
specs: [{ file: "x.spec.ts", title: "t", tests: [{ results: [{ status: "weird" }] }] }],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
strict_1.default.equal(out[0].status, "fail");
|
|
77
|
+
});
|
|
78
|
+
(0, node_test_1.test)("uses the LAST retry result for status/duration/error", () => {
|
|
79
|
+
const out = (0, report_1.parsePlaywrightReport)({
|
|
80
|
+
suites: [
|
|
81
|
+
{
|
|
82
|
+
file: "retry.spec.ts",
|
|
83
|
+
specs: [
|
|
84
|
+
{
|
|
85
|
+
file: "retry.spec.ts",
|
|
86
|
+
title: "flaky then passes",
|
|
87
|
+
tests: [
|
|
88
|
+
{
|
|
89
|
+
results: [
|
|
90
|
+
{ status: "failed", duration: 500, error: { message: "first attempt failed" } },
|
|
91
|
+
{ status: "passed", duration: 900, error: undefined },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
strict_1.default.equal(out[0].status, "pass");
|
|
101
|
+
strict_1.default.equal(out[0].duration_ms, 900);
|
|
102
|
+
strict_1.default.equal(out[0].error_message, "");
|
|
103
|
+
});
|
|
104
|
+
(0, node_test_1.test)("extracts error message from an object and a bare string", () => {
|
|
105
|
+
const withObjectError = (0, report_1.parsePlaywrightReport)({
|
|
106
|
+
suites: [
|
|
107
|
+
{
|
|
108
|
+
file: "e1.spec.ts",
|
|
109
|
+
specs: [{ file: "e1.spec.ts", title: "t", tests: [{ results: [{ status: "failed", error: { message: "boom" } }] }] }],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
strict_1.default.equal(withObjectError[0].error_message, "boom");
|
|
114
|
+
const withStringError = (0, report_1.parsePlaywrightReport)({
|
|
115
|
+
suites: [
|
|
116
|
+
{
|
|
117
|
+
file: "e2.spec.ts",
|
|
118
|
+
specs: [{ file: "e2.spec.ts", title: "t", tests: [{ results: [{ status: "failed", error: "plain string" }] }] }],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
strict_1.default.equal(withStringError[0].error_message, "plain string");
|
|
123
|
+
});
|
|
124
|
+
(0, node_test_1.test)("filters attachments to known kinds and drops attachments without a path", () => {
|
|
125
|
+
const out = (0, report_1.parsePlaywrightReport)({
|
|
126
|
+
suites: [
|
|
127
|
+
{
|
|
128
|
+
file: "att.spec.ts",
|
|
129
|
+
specs: [
|
|
130
|
+
{
|
|
131
|
+
file: "att.spec.ts",
|
|
132
|
+
title: "t",
|
|
133
|
+
tests: [
|
|
134
|
+
{
|
|
135
|
+
results: [
|
|
136
|
+
{
|
|
137
|
+
status: "failed",
|
|
138
|
+
attachments: [
|
|
139
|
+
{ name: "screenshot", path: "/tmp/shot.png" },
|
|
140
|
+
{ name: "video", path: "/tmp/vid.webm" },
|
|
141
|
+
{ name: "trace", path: "/tmp/trace.zip" },
|
|
142
|
+
{ name: "stdout", path: "/tmp/stdout.txt" },
|
|
143
|
+
{ name: "screenshot", path: "" },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
strict_1.default.deepEqual(out[0].attachments, [
|
|
155
|
+
{ kind: "screenshot", path: "/tmp/shot.png" },
|
|
156
|
+
{ kind: "video", path: "/tmp/vid.webm" },
|
|
157
|
+
{ kind: "trace", path: "/tmp/trace.zip" },
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
(0, node_test_1.test)("flattens nested describe-block suites under one file", () => {
|
|
161
|
+
const out = (0, report_1.parsePlaywrightReport)({
|
|
162
|
+
suites: [
|
|
163
|
+
{
|
|
164
|
+
file: "nested.spec.ts",
|
|
165
|
+
specs: [],
|
|
166
|
+
suites: [
|
|
167
|
+
{
|
|
168
|
+
specs: [{ title: "inner test", tests: [{ results: [{ status: "passed", duration: 5 }] }] }],
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
strict_1.default.equal(out.length, 1);
|
|
175
|
+
strict_1.default.equal(out[0].file, "nested.spec.ts");
|
|
176
|
+
strict_1.default.equal(out[0].title, "inner test");
|
|
177
|
+
});
|
|
178
|
+
(0, node_test_1.test)("empty results array yields a fail with zero duration", () => {
|
|
179
|
+
const out = (0, report_1.parsePlaywrightReport)({
|
|
180
|
+
suites: [{ file: "none.spec.ts", specs: [{ file: "none.spec.ts", title: "t", tests: [{ results: [] }] }] }],
|
|
181
|
+
});
|
|
182
|
+
strict_1.default.equal(out[0].status, "fail");
|
|
183
|
+
strict_1.default.equal(out[0].duration_ms, 0);
|
|
184
|
+
});
|
|
185
|
+
(0, node_test_1.test)("specFilename strips the ticket prefix down to its short suffix", () => {
|
|
186
|
+
strict_1.default.equal((0, report_1.specFilename)("SUR-1428", "TC-01"), "1428-TC-01.spec.ts");
|
|
187
|
+
strict_1.default.equal((0, report_1.specFilename)("1428", "TC-02"), "1428-TC-02.spec.ts");
|
|
188
|
+
});
|
|
189
|
+
(0, node_test_1.test)("parseSpecIdentity recovers shortTicket + caseCode from the TC-NN convention", () => {
|
|
190
|
+
strict_1.default.deepEqual((0, report_1.parseSpecIdentity)("1428-TC-01.spec.ts"), { shortTicket: "1428", caseCode: "TC-01" });
|
|
191
|
+
strict_1.default.deepEqual((0, report_1.parseSpecIdentity)("weird-name.spec.ts"), { shortTicket: "weird-name", caseCode: "" });
|
|
192
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@q-agent/agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Q-Agent Local Agent — claims execution jobs from the Q-Agent server and runs Playwright locally, so manual login/MFA happens on the user's own machine and session credentials never leave it.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"bin": {
|
|
7
|
+
"qagent-agent": "dist/src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"vendor",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/chuongduong2810/q-agent.git",
|
|
17
|
+
"directory": "agent"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"test": "npm run build && node --test dist/test",
|
|
28
|
+
"prepublishOnly": "npm run build"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@playwright/test": "^1.61.1",
|
|
32
|
+
"commander": "^12.1.0",
|
|
33
|
+
"playwright": "^1.61.1"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^20.14.0",
|
|
37
|
+
"typescript": "^5.5.4"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Manual-login capture that survives Microsoft/Entra's anti-automation checks.
|
|
2
|
+
// Args: baseUrl, storageDest, sessionDest.
|
|
3
|
+
//
|
|
4
|
+
// Approach: launch a NORMAL Chrome/Edge via a plain command (with a remote
|
|
5
|
+
// debugging port) — NOT via Playwright's launcher — so the page sees a real,
|
|
6
|
+
// non-automated browser and federated sign-in (Microsoft) works without looping.
|
|
7
|
+
// Playwright only ATTACHES read-only over CDP to snapshot cookies+localStorage
|
|
8
|
+
// (storageState) and sessionStorage (where MSAL/SPA tokens live). We finish when
|
|
9
|
+
// the operator closes the browser.
|
|
10
|
+
const { chromium } = require('playwright');
|
|
11
|
+
const { spawn } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const [, , baseUrl, storageDest, sessionDest] = process.argv;
|
|
15
|
+
|
|
16
|
+
process.on('unhandledRejection', (e) => console.error('capture unhandledRejection:', e && (e.message || e)));
|
|
17
|
+
process.on('uncaughtException', (e) => console.error('capture uncaughtException:', e && (e.message || e)));
|
|
18
|
+
|
|
19
|
+
const profileDir = path.join(path.dirname(storageDest), 'browser-profile');
|
|
20
|
+
const PORT = 9222 + Math.floor(Math.random() * 400);
|
|
21
|
+
|
|
22
|
+
function findBrowser() {
|
|
23
|
+
const c = [
|
|
24
|
+
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
|
25
|
+
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
26
|
+
(process.env.LOCALAPPDATA || '') + '/Google/Chrome/Application/chrome.exe',
|
|
27
|
+
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
|
28
|
+
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
|
|
29
|
+
];
|
|
30
|
+
for (const p of c) { try { if (p && fs.existsSync(p)) return p; } catch {} }
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function waitForCDP(port, timeoutMs) {
|
|
35
|
+
const end = Date.now() + timeoutMs;
|
|
36
|
+
while (Date.now() < end) {
|
|
37
|
+
try { const r = await fetch(`http://127.0.0.1:${port}/json/version`); if (r.ok) return true; } catch {}
|
|
38
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
(async () => {
|
|
44
|
+
const exe = findBrowser();
|
|
45
|
+
if (!exe) { console.error('capture_auth: no Chrome/Edge found on this machine'); process.exit(1); }
|
|
46
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
// Fresh profiles block third-party cookies by default, which breaks the
|
|
49
|
+
// MSAL/Entra federation redirects (app <-> ciamlogin.com <-> login.microsoftonline.com)
|
|
50
|
+
// and makes the Microsoft sign-in loop. Pre-seed the profile to allow cookies.
|
|
51
|
+
try {
|
|
52
|
+
const defDir = path.join(profileDir, 'Default');
|
|
53
|
+
fs.mkdirSync(defDir, { recursive: true });
|
|
54
|
+
const prefsPath = path.join(defDir, 'Preferences');
|
|
55
|
+
if (!fs.existsSync(prefsPath)) {
|
|
56
|
+
fs.writeFileSync(prefsPath, JSON.stringify({
|
|
57
|
+
profile: { cookie_controls_mode: 0, block_third_party_cookies: false,
|
|
58
|
+
default_content_setting_values: { cookies: 1 } },
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
} catch (e) { console.error('pref seed failed:', e && e.message); }
|
|
62
|
+
|
|
63
|
+
// Launch a real, non-automated browser. No Playwright launch flags => no
|
|
64
|
+
// automation fingerprint => Microsoft login behaves normally.
|
|
65
|
+
const child = spawn(exe, [
|
|
66
|
+
`--remote-debugging-port=${PORT}`,
|
|
67
|
+
`--user-data-dir=${profileDir}`,
|
|
68
|
+
'--no-first-run', '--no-default-browser-check', '--new-window',
|
|
69
|
+
baseUrl,
|
|
70
|
+
], { detached: false, stdio: 'ignore' });
|
|
71
|
+
console.error('capture launched real browser:', exe, 'port', PORT);
|
|
72
|
+
|
|
73
|
+
if (!(await waitForCDP(PORT, 20000))) {
|
|
74
|
+
console.error('capture_auth: CDP endpoint never came up on port', PORT);
|
|
75
|
+
try { child.kill(); } catch {}
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${PORT}`);
|
|
80
|
+
const context = browser.contexts()[0];
|
|
81
|
+
|
|
82
|
+
// IMPORTANT: never call context.storageState() here. For origins whose tab has
|
|
83
|
+
// navigated away, Playwright reads their localStorage by OPENING A TEMP TAB to
|
|
84
|
+
// that origin — every snapshot — which the operator sees as a tab rapidly
|
|
85
|
+
// flashing open/closed during the federated (Microsoft) login. Instead we
|
|
86
|
+
// compose the state ourselves: cookies via context.cookies() (no page churn),
|
|
87
|
+
// and local/sessionStorage read only from tabs that are already open, merged
|
|
88
|
+
// across snapshots so nothing is lost when the flow navigates between origins.
|
|
89
|
+
const localByOrigin = {}; // origin -> { key: value }
|
|
90
|
+
const sessionByOrigin = {}; // origin -> { key: value }
|
|
91
|
+
|
|
92
|
+
async function snapshot() {
|
|
93
|
+
if (!context) return;
|
|
94
|
+
for (const p of context.pages()) {
|
|
95
|
+
try {
|
|
96
|
+
const dump = await p.evaluate(() => {
|
|
97
|
+
const read = (s) => {
|
|
98
|
+
const out = {};
|
|
99
|
+
for (let i = 0; i < s.length; i++) { const k = s.key(i); out[k] = s.getItem(k); }
|
|
100
|
+
return out;
|
|
101
|
+
};
|
|
102
|
+
return { origin: location.origin, local: read(localStorage), session: read(sessionStorage) };
|
|
103
|
+
});
|
|
104
|
+
if (!dump.origin || !dump.origin.startsWith('http')) continue;
|
|
105
|
+
if (Object.keys(dump.local).length) localByOrigin[dump.origin] = dump.local;
|
|
106
|
+
if (Object.keys(dump.session).length) sessionByOrigin[dump.origin] = dump.session;
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const cookies = await context.cookies();
|
|
111
|
+
const origins = Object.entries(localByOrigin).map(([origin, kv]) => ({
|
|
112
|
+
origin,
|
|
113
|
+
localStorage: Object.entries(kv).map(([name, value]) => ({ name, value })),
|
|
114
|
+
}));
|
|
115
|
+
fs.writeFileSync(storageDest, JSON.stringify({ cookies, origins }, null, 2));
|
|
116
|
+
} catch {}
|
|
117
|
+
try { fs.writeFileSync(sessionDest, JSON.stringify(sessionByOrigin, null, 2)); } catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const timer = setInterval(() => { snapshot().catch(() => {}); }, 1500);
|
|
121
|
+
|
|
122
|
+
// Finish when the operator closes the browser (child exits) or CDP drops.
|
|
123
|
+
await new Promise((resolve) => {
|
|
124
|
+
child.on('exit', resolve);
|
|
125
|
+
browser.on('disconnected', resolve);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
clearInterval(timer);
|
|
129
|
+
await snapshot().catch(() => {});
|
|
130
|
+
try { await browser.close(); } catch {}
|
|
131
|
+
try { child.kill(); } catch {}
|
|
132
|
+
process.exit(0);
|
|
133
|
+
})().catch((e) => {
|
|
134
|
+
console.error('capture_auth fatal:', e && (e.stack || e.message || e));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
});
|