@capillarytech/cap-ui-utils 3.0.20 → 3.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/e2e/README.md +57 -0
- package/e2e/constants/common.ts +23 -0
- package/e2e/index.ts +30 -0
- package/e2e/pages/common/base.page.ts +11 -0
- package/e2e/pages/common/constant.ts +2 -0
- package/e2e/pages/common/login.page.ts +95 -0
- package/e2e/services/lockService.ts +210 -0
- package/e2e/services/locks/beeTemplateReady.signal +1 -0
- package/e2e/utils/antdVersionUtil.ts +190 -0
- package/e2e/utils/automationBypassUtil.ts +46 -0
- package/e2e/utils/debugModeUtil.ts +32 -0
- package/e2e/utils/deletionRegistry.ts +15 -0
- package/e2e/utils/elementUtil.ts +757 -0
- package/e2e/utils/expectUtil.ts +19 -0
- package/e2e/utils/featureFlagUtil.ts +30 -0
- package/e2e/utils/garudaDropdownUtil.ts +60 -0
- package/e2e/utils/htmlEditorUtil.ts +56 -0
- package/e2e/utils/logCollectorUtil.ts +52 -0
- package/e2e/utils/mockResponse.ts +24 -0
- package/e2e/utils/reportUploader.ts +172 -0
- package/e2e/utils/requestRecorderUtil.ts +178 -0
- package/e2e/utils/screenshotRecorderUtil.ts +174 -0
- package/e2e/utils/setupMock.ts +29 -0
- package/e2e/utils/unmatchedBracesUtil.ts +101 -0
- package/e2e/utils/uploaders/fileServiceUploader.ts +84 -0
- package/e2e/utils/uploaders/uploader.ts +20 -0
- package/e2e/utils/virtualListUtil.ts +115 -0
- package/package.json +15 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
import * as assert from 'assert'
|
|
3
|
+
|
|
4
|
+
class AssertionUtil {
|
|
5
|
+
|
|
6
|
+
async assertValue(element:WebdriverIO.Element, textToBeValidated:string){
|
|
7
|
+
assert.strictEqual(await element.getText(),textToBeValidated);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async assertElementExistence(element:WebdriverIO.Element){
|
|
11
|
+
try {
|
|
12
|
+
assert.strictEqual(await element.isDisplayed(), true);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
throw new Error(error)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default new AssertionUtil()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads feature flags from window.capAuth.accessibleFeatures — the same source
|
|
3
|
+
* that Auth.hasFeatureAccess() uses in the app (cap-ui-utils/auth/hasFeatureAccess.js).
|
|
4
|
+
*
|
|
5
|
+
* Call loadFeatureFlags() once after the app has loaded (e.g. after openAudiencePage),
|
|
6
|
+
* then use hasFeatureAccess() anywhere in the test run.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
let _accessibleFeatures: string[] = [];
|
|
10
|
+
|
|
11
|
+
async function loadFeatureFlags(): Promise<void> {
|
|
12
|
+
// window.capAuth.accessibleFeatures is populated async after the app boots.
|
|
13
|
+
// Wait until it's array-shaped (up to 30s) before caching — an empty array
|
|
14
|
+
// is a valid "no features enabled" state, not a sign it's still loading.
|
|
15
|
+
await browser.waitUntil(
|
|
16
|
+
async () => {
|
|
17
|
+
const features = await browser.execute(() => (window as any).capAuth?.accessibleFeatures);
|
|
18
|
+
return Array.isArray(features);
|
|
19
|
+
},
|
|
20
|
+
{ timeout: 30000, interval: 1000, timeoutMsg: '[featureFlags] window.capAuth.accessibleFeatures never populated' }
|
|
21
|
+
);
|
|
22
|
+
_accessibleFeatures = await browser.execute(() => (window as any).capAuth?.accessibleFeatures || []);
|
|
23
|
+
console.log(`[featureFlags] loaded ${_accessibleFeatures.length} features`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasFeatureAccess(feature: string): boolean {
|
|
27
|
+
return _accessibleFeatures.includes(feature);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default { loadFeatureFlags, hasFeatureAccess };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
class GarudaDropdownUtil {
|
|
2
|
+
treeOptionByText(
|
|
3
|
+
text: string,
|
|
4
|
+
index: number | 'last' = 1,
|
|
5
|
+
altText?: string
|
|
6
|
+
): WebdriverIO.Element {
|
|
7
|
+
const textMatch = altText
|
|
8
|
+
? `normalize-space()="${text}" or normalize-space()="${altText}"`
|
|
9
|
+
: `normalize-space()="${text}"`;
|
|
10
|
+
const position = index === 'last' ? 'last()' : index;
|
|
11
|
+
return $(
|
|
12
|
+
`(//span[contains(@class,"ant-select-tree-node-content-wrapper")][.//div[${textMatch}]])[${position}]`
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
unifiedOptionByText(text: string): WebdriverIO.Element {
|
|
17
|
+
return $(
|
|
18
|
+
`//div[contains(@class,"cap-unified-select-popup")]//div[.//div[text()="${text}"] and @role="treeitem"]`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
listOptionByLabel(label: string, index: number = 1): WebdriverIO.Element {
|
|
23
|
+
return $(
|
|
24
|
+
`(//ul[@role="listbox"]//li[@role="option" and @label="${label}"])[${index}]`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
selectOptionByText(text: string, index: number = 1): WebdriverIO.Element {
|
|
29
|
+
return $(
|
|
30
|
+
`(//div[contains(@class,"ant-select-item-option")][.//div[contains(@class,"ant-select-item-option-content")][normalize-space()="${text}"]])[${index}]`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
treeOption(index: number | 'last' = 1): WebdriverIO.Element {
|
|
35
|
+
const position = index === 'last' ? 'last()' : index;
|
|
36
|
+
return $(
|
|
37
|
+
`(//span[contains(@class,"ant-select-tree-node-content-wrapper")][normalize-space(.)])[${position}]`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
listOptionByLabelInPopup(ariaControlId: string, label: string): WebdriverIO.Element {
|
|
42
|
+
return $(
|
|
43
|
+
`//div[contains(@id,"${ariaControlId}")]//ul//li[@role="option" and @label="${label}"]`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
menuItemByText(text: string, index: number = 1): WebdriverIO.Element {
|
|
48
|
+
return $(
|
|
49
|
+
`(//li[contains(@class,"ant-dropdown-menu-item")][.//span[normalize-space()="${text}"]])[${index}]`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
unifiedSelectConfirmButton(): WebdriverIO.Element {
|
|
54
|
+
return $(
|
|
55
|
+
`//button[contains(@class,"cap-unified-select-confirm-button")]`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default new GarudaDropdownUtil();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the antd v6 contenteditable HTML editor.
|
|
3
|
+
* After bulk HTML injection, the caret remains outside the `<body>` tag, causing label
|
|
4
|
+
* insertions at the wrong position. `placeCaretInsideBody` locates the `<body>` position
|
|
5
|
+
* across text nodes and updates the Selection range correctly.
|
|
6
|
+
*
|
|
7
|
+
* Caller passes the WDIO editor element (defined as a page selector), so this util
|
|
8
|
+
* does not own selector strings.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export async function placeCaretInsideBody(
|
|
12
|
+
editor: WebdriverIO.Element,
|
|
13
|
+
): Promise<boolean> {
|
|
14
|
+
return browser.execute((node: HTMLElement) => {
|
|
15
|
+
if (!node) return false;
|
|
16
|
+
|
|
17
|
+
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
|
18
|
+
const nodes: Text[] = [];
|
|
19
|
+
const starts: number[] = [];
|
|
20
|
+
let combined = '';
|
|
21
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
22
|
+
const t = n as Text;
|
|
23
|
+
starts.push(combined.length);
|
|
24
|
+
combined += t.nodeValue || '';
|
|
25
|
+
nodes.push(t);
|
|
26
|
+
}
|
|
27
|
+
if (!nodes.length) return false;
|
|
28
|
+
|
|
29
|
+
const open = combined.match(/<\s*body\b[^>]*>/i);
|
|
30
|
+
if (!open || open.index === undefined) return false;
|
|
31
|
+
|
|
32
|
+
let idx = open.index + open[0].length;
|
|
33
|
+
while (idx < combined.length && /\s/.test(combined.charAt(idx))) idx++;
|
|
34
|
+
|
|
35
|
+
let nodeIdx = 0;
|
|
36
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
37
|
+
const end = starts[i] + (nodes[i].nodeValue || '').length;
|
|
38
|
+
if (idx <= end) { nodeIdx = i; break; }
|
|
39
|
+
nodeIdx = i;
|
|
40
|
+
}
|
|
41
|
+
const target = nodes[nodeIdx];
|
|
42
|
+
const offset = Math.max(0, Math.min(idx - starts[nodeIdx], (target.nodeValue || '').length));
|
|
43
|
+
|
|
44
|
+
node.focus();
|
|
45
|
+
const range = document.createRange();
|
|
46
|
+
range.setStart(target, offset);
|
|
47
|
+
range.collapse(true);
|
|
48
|
+
const selection = window.getSelection();
|
|
49
|
+
if (!selection) return false;
|
|
50
|
+
selection.removeAllRanges();
|
|
51
|
+
selection.addRange(range);
|
|
52
|
+
return true;
|
|
53
|
+
}, editor as any);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default { placeCaretInsideBody };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import supertest from 'supertest'
|
|
2
|
+
import commands from "@rpii/wdio-commands"
|
|
3
|
+
|
|
4
|
+
class LogCollectorUtil {
|
|
5
|
+
|
|
6
|
+
callCount=0;
|
|
7
|
+
|
|
8
|
+
async collectLogs(runId, test, error, duration, passed, skipped = false){
|
|
9
|
+
this.callCount += 1;
|
|
10
|
+
let validationMessage
|
|
11
|
+
let status
|
|
12
|
+
|
|
13
|
+
if(passed){
|
|
14
|
+
status='passed'
|
|
15
|
+
validationMessage = ''
|
|
16
|
+
} else if(skipped){
|
|
17
|
+
status='skipped'
|
|
18
|
+
validationMessage = ''
|
|
19
|
+
} else {
|
|
20
|
+
commands.logScreenshot("Exception logged: "+error)
|
|
21
|
+
status='failed'
|
|
22
|
+
if(error===undefined){
|
|
23
|
+
validationMessage='cannot capture error'
|
|
24
|
+
} else {
|
|
25
|
+
validationMessage=error.toString().replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// this.recordBrowserLogs()
|
|
29
|
+
const baseUrl = 'http://127.0.0.1:4000'
|
|
30
|
+
const request = supertest(baseUrl)
|
|
31
|
+
let exeTime = duration / 1000
|
|
32
|
+
let message ={[runId]: {[test.parent]: {[test.title]: {"status": status,
|
|
33
|
+
"time": exeTime.toString(), "validataionMessage": validationMessage,
|
|
34
|
+
"total": this.callCount}}}}
|
|
35
|
+
const messagePayload ={"result" : message}
|
|
36
|
+
const response = await request.
|
|
37
|
+
post('/logger')
|
|
38
|
+
.send(messagePayload)
|
|
39
|
+
console.log('API Response',response.body);
|
|
40
|
+
console.log('Message', message)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async recordBrowserLogs(){
|
|
44
|
+
const logTypes = Promise.resolve(browser.getLogs('browser')).then(function(data){return data})
|
|
45
|
+
logTypes.then(data=>(JSON.stringify(data.filter(function getObj(logs) {
|
|
46
|
+
if(logs['level']==='SEVERE' || logs['level']==='ERROR'){
|
|
47
|
+
commands.logMessage((logs['level']+": "+JSON.stringify(logs)))}
|
|
48
|
+
})))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export default new LogCollectorUtil()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const beamerApiResp = {
|
|
2
|
+
"activateAutoRefresh": true,
|
|
3
|
+
"autoRefreshTimeout": 12000000,
|
|
4
|
+
"defaultSelectorColor": "#50ac44",
|
|
5
|
+
"enableAutoRefresh": true,
|
|
6
|
+
"enableFaviconNotification": false,
|
|
7
|
+
"enableSoundNotification": false,
|
|
8
|
+
"enableUpdatesListener": true,
|
|
9
|
+
"enabled": false,
|
|
10
|
+
"filterByUrl": true,
|
|
11
|
+
"headerColor": "#50ac44",
|
|
12
|
+
"lastPostBoostedBackgroundColor": "#0099ff",
|
|
13
|
+
"lastPostBoostedTextColor": "#ffffff",
|
|
14
|
+
"logoUrl": "https://static.getbeamer.com/vnosnxuz35404/logo_small_5457.png",
|
|
15
|
+
"notificationColor": "#ff5a5f",
|
|
16
|
+
"pushNotificationsPrompt": "We'd like update you about upcoming feature releases",
|
|
17
|
+
"pushNotificationsPromptAccept": "Allow",
|
|
18
|
+
"pushNotificationsPromptEnabled": false,
|
|
19
|
+
"pushNotificationsPromptRefuse": "No, thanks",
|
|
20
|
+
"pushNotificationsPromptType": "popup",
|
|
21
|
+
"showLinkInSnippet": false,
|
|
22
|
+
"topDomain": "capillarytech.com",
|
|
23
|
+
"updatesDelay": 300000
|
|
24
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { execFile, execFileSync } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { Uploader } from './uploaders/uploader';
|
|
6
|
+
import { FileServiceUploader } from './uploaders/fileServiceUploader';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Zips the recorded debug artifacts (request payloads + screenshots) into a
|
|
12
|
+
* single .zip and uploads it via a pluggable Uploader (Capillary File Service
|
|
13
|
+
* now; swap backends by changing one line in getUploader()).
|
|
14
|
+
*
|
|
15
|
+
* Always uploads when debug capture is on (the service that calls this is only
|
|
16
|
+
* registered when FULL_DEBUG_MODE is enabled).
|
|
17
|
+
*/
|
|
18
|
+
class ReportUploader {
|
|
19
|
+
private debugDir = path.join(process.cwd(), 'reports', 'debug');
|
|
20
|
+
// Upper bound for the whole upload step; on expiry we log and let the run end.
|
|
21
|
+
private uploadTimeoutMs = Number(process.env.UPLOAD_TIMEOUT_MS) || 90000;
|
|
22
|
+
|
|
23
|
+
private getUploader(): Uploader {
|
|
24
|
+
return new FileServiceUploader();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Remove prior-run artifacts so each archive contains only the current run. */
|
|
28
|
+
clearPreviousArtifacts(): void {
|
|
29
|
+
try {
|
|
30
|
+
if (fs.existsSync(this.debugDir)) {
|
|
31
|
+
fs.rmSync(this.debugDir, { recursive: true, force: true });
|
|
32
|
+
console.log('[reportUploader] cleared previous debug artifacts');
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.log(`[reportUploader] failed to clear previous artifacts: ${err}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async archiveAndUpload(): Promise<void> {
|
|
40
|
+
try {
|
|
41
|
+
if (!fs.existsSync(this.debugDir) || fs.readdirSync(this.debugDir).length === 0) {
|
|
42
|
+
console.log('[reportUploader] no debug artifacts recorded — nothing to upload');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Bundle run metadata + a snapshot of the automation code so a later
|
|
47
|
+
// diff can tell whether a payload change came from the lib upgrade or
|
|
48
|
+
// from the tests themselves.
|
|
49
|
+
this.writeMetaAndCode();
|
|
50
|
+
|
|
51
|
+
const archivePath = await this.createArchive();
|
|
52
|
+
console.log(`[reportUploader] created archive ${archivePath}`);
|
|
53
|
+
|
|
54
|
+
const uploader = this.getUploader();
|
|
55
|
+
if (!uploader.isConfigured()) {
|
|
56
|
+
console.log(`[reportUploader] upload skipped — missing config: ${uploader.missingConfigMessage()}. Archive kept locally at ${archivePath}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Belt-and-suspenders: even with the uploader's own network timeout,
|
|
61
|
+
// never let the upload block the run's onComplete hook (a hung upload
|
|
62
|
+
// would keep the pod alive forever). On timeout we log and move on so
|
|
63
|
+
// the pod terminates exactly as it did before this feature existed.
|
|
64
|
+
const location = await this.withTimeout(
|
|
65
|
+
uploader.upload(archivePath, path.basename(archivePath)),
|
|
66
|
+
this.uploadTimeoutMs,
|
|
67
|
+
'upload',
|
|
68
|
+
);
|
|
69
|
+
console.log(`[reportUploader] uploaded request payloads to ${uploader.name}: ${location}`);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.log(`[reportUploader] archive/upload failed (continuing so the run can exit): ${err}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reject after ms so a stuck upload can never block process shutdown. */
|
|
76
|
+
private withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
77
|
+
let timer: NodeJS.Timeout;
|
|
78
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
80
|
+
// Don't let this timer itself keep the event loop alive.
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
});
|
|
83
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise<T>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Run a git command and return its trimmed stdout, or '' on failure. */
|
|
87
|
+
private git(args: string[]): string {
|
|
88
|
+
try {
|
|
89
|
+
return execFileSync('git', args, { cwd: process.cwd() }).toString().trim();
|
|
90
|
+
} catch {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Write debug/meta.json (run identity + git state) and copy the automation
|
|
97
|
+
* code for this module into debug/code/. The compare tool reads these to
|
|
98
|
+
* flag when two archives were produced from different code, so payload diffs
|
|
99
|
+
* aren't misattributed to the UI library upgrade.
|
|
100
|
+
*/
|
|
101
|
+
private writeMetaAndCode(): void {
|
|
102
|
+
try {
|
|
103
|
+
const moduleName = process.env.module || 'all';
|
|
104
|
+
const commit = this.git(['rev-parse', 'HEAD']);
|
|
105
|
+
const branch = this.git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
106
|
+
const dirty = this.git(['status', '--porcelain']).length > 0;
|
|
107
|
+
|
|
108
|
+
const meta = {
|
|
109
|
+
cluster: process.env.cluster || 'unknown-cluster',
|
|
110
|
+
module: moduleName,
|
|
111
|
+
testType: this.getTestType(),
|
|
112
|
+
recordedAt: new Date().toISOString(),
|
|
113
|
+
git: { commit, branch, dirty },
|
|
114
|
+
};
|
|
115
|
+
fs.writeFileSync(
|
|
116
|
+
path.join(this.debugDir, 'meta.json'),
|
|
117
|
+
JSON.stringify(meta, null, 2),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Snapshot only this module's automation code (small); never the whole
|
|
121
|
+
// src/ tree (~110M). Page objects + specs are enough to explain a diff.
|
|
122
|
+
const codeDir = path.join(this.debugDir, 'code');
|
|
123
|
+
const sources = [
|
|
124
|
+
path.join('test', moduleName),
|
|
125
|
+
path.join('src', 'pages', moduleName),
|
|
126
|
+
];
|
|
127
|
+
for (const rel of sources) {
|
|
128
|
+
const abs = path.join(process.cwd(), rel);
|
|
129
|
+
if (!fs.existsSync(abs)) continue;
|
|
130
|
+
const dest = path.join(codeDir, rel);
|
|
131
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
132
|
+
fs.cpSync(abs, dest, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.log(`[reportUploader] failed to write meta/code snapshot: ${err}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Resolve the test type (suite) from the `--suite <name>` CLI flag. */
|
|
140
|
+
private getTestType(): string {
|
|
141
|
+
const argv = process.argv;
|
|
142
|
+
const idx = argv.findIndex((a) => a === '--suite');
|
|
143
|
+
if (idx !== -1 && argv[idx + 1]) return argv[idx + 1];
|
|
144
|
+
const inline = argv.find((a) => a.startsWith('--suite='));
|
|
145
|
+
if (inline) return inline.split('=')[1];
|
|
146
|
+
return 'all';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async createArchive(): Promise<string> {
|
|
150
|
+
const cluster = process.env.cluster || 'unknown-cluster';
|
|
151
|
+
const moduleName = process.env.module || 'all';
|
|
152
|
+
const testType = this.getTestType();
|
|
153
|
+
|
|
154
|
+
// date = YYYY-MM-DD, time = HH-MM-SS (UTC, from ISO timestamp)
|
|
155
|
+
const [datePart, timePartRaw] = new Date().toISOString().split('T');
|
|
156
|
+
const timePart = timePartRaw.split('.')[0].replace(/:/g, '-');
|
|
157
|
+
|
|
158
|
+
const archiveName = `wdio-ui-${cluster}_${moduleName}_${testType}_${datePart}_${timePart}.zip`;
|
|
159
|
+
const archivePath = path.join(process.cwd(), 'reports', archiveName);
|
|
160
|
+
|
|
161
|
+
// Run zip from the parent dir so the archive contains the debug/ folder
|
|
162
|
+
// with relative paths, not absolute ones.
|
|
163
|
+
await execFileAsync(
|
|
164
|
+
'zip',
|
|
165
|
+
['-r', '-q', archivePath, path.basename(this.debugDir)],
|
|
166
|
+
{ cwd: path.dirname(this.debugDir) },
|
|
167
|
+
);
|
|
168
|
+
return archivePath;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default new ReportUploader();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import debugMode from './debugModeUtil';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Records request payloads (POST / PATCH / DELETE) sent from the UI during a
|
|
7
|
+
* test run and saves them to JSON files under reports/debug/request-payloads/.
|
|
8
|
+
*
|
|
9
|
+
* Off by default; enabled only when FULL_DEBUG_MODE=true. When off, every
|
|
10
|
+
* method is a no-op so there is zero overhead.
|
|
11
|
+
*
|
|
12
|
+
* One JSON file is written per test, containing an array of all the
|
|
13
|
+
* POST / PATCH / DELETE requests captured while that test was running.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface InitiatorFrame {
|
|
17
|
+
functionName: string;
|
|
18
|
+
url: string;
|
|
19
|
+
line: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RequestInitiator {
|
|
23
|
+
// 'script' (a JS handler fired it), 'parser', 'preflight', 'other', ...
|
|
24
|
+
type: string;
|
|
25
|
+
// Top JS call frames that triggered the request — i.e. which handler fired it.
|
|
26
|
+
stack: InitiatorFrame[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RecordedRequest {
|
|
30
|
+
requestId: string;
|
|
31
|
+
method: string;
|
|
32
|
+
url: string;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
postData: string | null;
|
|
35
|
+
headers: Record<string, string>;
|
|
36
|
+
// Which app code initiated the request (from CDP initiator).
|
|
37
|
+
initiator: RequestInitiator | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const RECORDED_METHODS = ['POST', 'PATCH', 'DELETE', 'PUT'];
|
|
41
|
+
// How many call frames to keep — enough to identify the handler without bloat.
|
|
42
|
+
const MAX_INITIATOR_FRAMES = 3;
|
|
43
|
+
|
|
44
|
+
class RequestRecorder {
|
|
45
|
+
// Off by default; record only when capture is enabled for this module
|
|
46
|
+
// (FULL_DEBUG_MODE=true and, if DEBUG_MODULES is set, module is listed).
|
|
47
|
+
private enabled = debugMode.isCaptureEnabled();
|
|
48
|
+
private outputDir = path.join(process.cwd(), 'reports', 'debug', 'request-payloads');
|
|
49
|
+
private currentTestTitle = '';
|
|
50
|
+
private buffer: RecordedRequest[] = [];
|
|
51
|
+
private seenRequestIds = new Set<string>();
|
|
52
|
+
|
|
53
|
+
isEnabled(): boolean {
|
|
54
|
+
return this.enabled;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reset the buffer and remember the test we are currently recording for. */
|
|
58
|
+
startTest(testTitle: string): void {
|
|
59
|
+
if (!this.enabled) return;
|
|
60
|
+
this.currentTestTitle = testTitle || 'unknown_test';
|
|
61
|
+
this.buffer = [];
|
|
62
|
+
this.seenRequestIds.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Capture a single request from a CDP Network.requestWillBeSent event.
|
|
67
|
+
* Only POST / PATCH / DELETE requests matching baseUrl are recorded.
|
|
68
|
+
* Large request bodies are fetched via Network.getRequestPostData when
|
|
69
|
+
* the event payload does not inline them.
|
|
70
|
+
*/
|
|
71
|
+
async record(params: any, baseUrl: string): Promise<void> {
|
|
72
|
+
if (!this.enabled) return;
|
|
73
|
+
try {
|
|
74
|
+
const request = params?.request;
|
|
75
|
+
if (!request) return;
|
|
76
|
+
|
|
77
|
+
const method = (request.method || '').toUpperCase();
|
|
78
|
+
if (!RECORDED_METHODS.includes(method)) return;
|
|
79
|
+
if (baseUrl && !String(request.url).startsWith(baseUrl)) return;
|
|
80
|
+
|
|
81
|
+
// CDP can emit requestWillBeSent more than once per request; dedupe by id.
|
|
82
|
+
const requestId = String(params.requestId);
|
|
83
|
+
if (this.seenRequestIds.has(requestId)) return;
|
|
84
|
+
this.seenRequestIds.add(requestId);
|
|
85
|
+
|
|
86
|
+
const entry: RecordedRequest = {
|
|
87
|
+
requestId,
|
|
88
|
+
method,
|
|
89
|
+
url: request.url,
|
|
90
|
+
timestamp: new Date().toISOString(),
|
|
91
|
+
postData: request.postData ?? null,
|
|
92
|
+
headers: request.headers ?? {},
|
|
93
|
+
initiator: this.extractInitiator(params?.initiator),
|
|
94
|
+
};
|
|
95
|
+
// Preserve ordering: push synchronously, backfill body if needed.
|
|
96
|
+
this.buffer.push(entry);
|
|
97
|
+
|
|
98
|
+
if (entry.postData == null && request.hasPostData) {
|
|
99
|
+
try {
|
|
100
|
+
const res: any = await browser.cdp('Network', 'getRequestPostData', {
|
|
101
|
+
requestId: params.requestId,
|
|
102
|
+
});
|
|
103
|
+
entry.postData = res?.postData ?? null;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.log(`[requestRecorder] could not fetch post data: ${err}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.log(`[requestRecorder] failed to record request: ${err}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reduce a CDP initiator to a compact, comparable shape: the request type
|
|
115
|
+
* and the top few JS call frames (the handler that fired the request).
|
|
116
|
+
* CDP line/column numbers are 0-based, so +1 for human-readable lines.
|
|
117
|
+
*/
|
|
118
|
+
private extractInitiator(initiator: any): RequestInitiator | null {
|
|
119
|
+
if (!initiator) return null;
|
|
120
|
+
const frames: InitiatorFrame[] = [];
|
|
121
|
+
const callFrames = initiator?.stack?.callFrames;
|
|
122
|
+
if (Array.isArray(callFrames)) {
|
|
123
|
+
for (const f of callFrames.slice(0, MAX_INITIATOR_FRAMES)) {
|
|
124
|
+
frames.push({
|
|
125
|
+
functionName: f?.functionName || '(anonymous)',
|
|
126
|
+
url: f?.url || '',
|
|
127
|
+
line: typeof f?.lineNumber === 'number' ? f.lineNumber + 1 : -1,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
} else if (initiator.url) {
|
|
131
|
+
// parser-initiated requests carry url/lineNumber directly.
|
|
132
|
+
frames.push({
|
|
133
|
+
functionName: '',
|
|
134
|
+
url: initiator.url,
|
|
135
|
+
line: typeof initiator.lineNumber === 'number' ? initiator.lineNumber + 1 : -1,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return { type: initiator.type || 'other', stack: frames };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Write the buffered requests for the current test to a JSON file. */
|
|
142
|
+
flushTest(): void {
|
|
143
|
+
if (!this.enabled) return;
|
|
144
|
+
try {
|
|
145
|
+
if (this.buffer.length === 0) return;
|
|
146
|
+
|
|
147
|
+
const moduleName = process.env.module || 'unknown_module';
|
|
148
|
+
const dir = path.join(this.outputDir, moduleName);
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
const safeTitle = this.currentTestTitle
|
|
152
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '_')
|
|
153
|
+
.slice(0, 120);
|
|
154
|
+
// Stable filename (no timestamp) so the same test maps to the same
|
|
155
|
+
// path across runs/zips — enables zip-to-zip payload diffing.
|
|
156
|
+
const fileName = `${safeTitle}.json`;
|
|
157
|
+
|
|
158
|
+
const payload = {
|
|
159
|
+
test: this.currentTestTitle,
|
|
160
|
+
module: moduleName,
|
|
161
|
+
recordedAt: new Date().toISOString(),
|
|
162
|
+
requestCount: this.buffer.length,
|
|
163
|
+
requests: this.buffer,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
fs.writeFileSync(path.join(dir, fileName), JSON.stringify(payload, null, 2));
|
|
167
|
+
console.log(`[requestRecorder] saved ${this.buffer.length} request(s) to ${path.join(dir, fileName)}`);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.log(`[requestRecorder] failed to write payload file: ${err}`);
|
|
170
|
+
} finally {
|
|
171
|
+
this.buffer = [];
|
|
172
|
+
this.seenRequestIds.clear();
|
|
173
|
+
this.currentTestTitle = '';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default new RequestRecorder();
|