@capillarytech/cap-ui-utils 3.0.20 → 3.1.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/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 +215 -0
- package/e2e/services/locks/.gitkeep +0 -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 +59 -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
package/e2e/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# `@capillarytech/cap-ui-utils/e2e` — shared WDIO E2E toolkit
|
|
2
|
+
|
|
3
|
+
The framework-agnostic, **data-free** E2E helpers that were duplicated byte-for-byte
|
|
4
|
+
across the product repos (`wdio-ui-automation`, `garuda-ui`, …). Centralizing them
|
|
5
|
+
here means one source of truth: a fix or a new helper lands once, and every product
|
|
6
|
+
repo picks it up on the next `@capillarytech/cap-ui-utils` bump.
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// whole toolkit
|
|
12
|
+
import { elementUtil, loginPage, antdVersion } from '@capillarytech/cap-ui-utils/e2e';
|
|
13
|
+
|
|
14
|
+
// or a single module (deep import — matches the old relative-path style)
|
|
15
|
+
import elementUtil from '@capillarytech/cap-ui-utils/e2e/utils/elementUtil';
|
|
16
|
+
import loginPage from '@capillarytech/cap-ui-utils/e2e/pages/common/login.page';
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The E2E test tooling (`webdriverio`, `@rpii/wdio-commands`, `axios`, `supertest`,
|
|
20
|
+
`nanoid`) is declared as **optional `peerDependencies`** — only repos that actually
|
|
21
|
+
run E2E install them; runtime apps that import `@capillarytech/cap-ui-utils` are
|
|
22
|
+
unaffected.
|
|
23
|
+
|
|
24
|
+
## What's here (this drop)
|
|
25
|
+
|
|
26
|
+
Only helpers that are **self-contained and free of per-repo test data**:
|
|
27
|
+
|
|
28
|
+
| Area | Modules |
|
|
29
|
+
|---|---|
|
|
30
|
+
| Element interaction | `utils/elementUtil` |
|
|
31
|
+
| antd version detection | `utils/antdVersionUtil` |
|
|
32
|
+
| Assertions | `utils/expectUtil` |
|
|
33
|
+
| Login flow | `pages/common/login.page`, `pages/common/base.page` |
|
|
34
|
+
| Reporting / recording | `utils/reportUploader`, `utils/logCollectorUtil`, `utils/requestRecorderUtil`, `utils/screenshotRecorderUtil` |
|
|
35
|
+
| Mocking | `utils/setupMock`, `utils/mockResponse` |
|
|
36
|
+
| Misc infra | `utils/debugModeUtil`, `utils/featureFlagUtil`, `utils/automationBypassUtil`, `utils/deletionRegistry`, `utils/garudaDropdownUtil`, `utils/htmlEditorUtil`, `utils/virtualListUtil`, `utils/unmatchedBracesUtil`, `utils/uploaders/*` |
|
|
37
|
+
| Services | `services/lockService`, `services/locks/*` |
|
|
38
|
+
| Config | `constants/common` (waits, log markers, `maxLoginAttempts`) |
|
|
39
|
+
|
|
40
|
+
Two small decouplings were applied on the way in:
|
|
41
|
+
- `logCollectorUtil` had a **dead** `import { config } from '../../test/wdio.conf'` (never used) — removed.
|
|
42
|
+
- `login.page` imported `maxLoginAttempts` from the data aggregator `constants/constants`; repointed to `constants/common` so the toolkit carries no org/auth data.
|
|
43
|
+
|
|
44
|
+
## Deliberately NOT here yet (data-coupled)
|
|
45
|
+
|
|
46
|
+
These read the per-repo `cons` object (merged `org`/`auth`/`endpoints`/`url` test
|
|
47
|
+
data, keyed by `cluster`/`module`) via `constants/constants`, so moving them as-is
|
|
48
|
+
would drag the whole per-tenant data layer into this shared package:
|
|
49
|
+
|
|
50
|
+
`utils/apiUtil`, `utils/helperUtil`, `utils/cookieUtil` (org switch),
|
|
51
|
+
`utils/navigationUtil`, `utils/appNavigationUtil`, `services/requestRecorderService`,
|
|
52
|
+
`pages/common/home.page`, `pages/common/testAndPreviewFlow.page`.
|
|
53
|
+
|
|
54
|
+
**Follow-up:** introduce a tiny config-injection seam (e.g. `e2e/config.setConfig(cons)`
|
|
55
|
+
called once in the consumer's `onPrepare`) so these helpers read config from the
|
|
56
|
+
package instead of importing the data aggregator. Then navigation / org-switch can
|
|
57
|
+
move too, and each repo keeps only its own `org`/`auth`/`endpoints` data.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Singapore clusters that require special handling
|
|
2
|
+
export const SG_CLUSTERS = ['sgcrm', 'sgcrmasia'];
|
|
3
|
+
|
|
4
|
+
export const INFO = "🟡";
|
|
5
|
+
export const SUCCESS = "🟢";
|
|
6
|
+
export const FAILURE = "🔴";
|
|
7
|
+
|
|
8
|
+
export const WAIT_30S = 30000;
|
|
9
|
+
export const WAIT_60S = 60000;
|
|
10
|
+
export const WAIT_90S = 90000;
|
|
11
|
+
export const WAIT_120S = 120000;
|
|
12
|
+
|
|
13
|
+
export const common = {
|
|
14
|
+
"cluster" : process.env.cluster,
|
|
15
|
+
"validatorUrl" : "http://127.0.0.1:4000",
|
|
16
|
+
"maxLoginAttempts": 3,
|
|
17
|
+
"sgClusters": SG_CLUSTERS,
|
|
18
|
+
"isLoyaltyMigration": process.env.module === 'loyalty_migration',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// login.page.ts (shared) imports this by name; re-exported here so the toolkit
|
|
22
|
+
// stays free of the data-heavy constants/constants aggregator (org/auth/endpoints).
|
|
23
|
+
export const maxLoginAttempts = common.maxLoginAttempts;
|
package/e2e/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared WDIO E2E toolkit — the framework-agnostic, data-free helpers that were
|
|
3
|
+
* duplicated byte-for-byte across the product repos (wdio-ui-automation,
|
|
4
|
+
* garuda-ui, and every future migrated repo).
|
|
5
|
+
*
|
|
6
|
+
* Two ways to consume:
|
|
7
|
+
* import { elementUtil, loginPage } from '@capillarytech/cap-ui-utils/e2e';
|
|
8
|
+
* import elementUtil from '@capillarytech/cap-ui-utils/e2e/utils/elementUtil';
|
|
9
|
+
*
|
|
10
|
+
* NOT included yet (data-coupled — they read the per-repo `cons` org/auth/endpoint
|
|
11
|
+
* data via constants/constants): apiUtil, helperUtil, cookieUtil, navigationUtil,
|
|
12
|
+
* appNavigationUtil, requestRecorderService, home.page, testAndPreviewFlow.page.
|
|
13
|
+
* These need a small config-injection seam before they can move — see README.
|
|
14
|
+
*/
|
|
15
|
+
export { default as elementUtil } from './utils/elementUtil';
|
|
16
|
+
export { default as antdVersion } from './utils/antdVersionUtil';
|
|
17
|
+
export { default as expectUtil } from './utils/expectUtil';
|
|
18
|
+
export { default as automationBypass } from './utils/automationBypassUtil';
|
|
19
|
+
export { default as debugMode } from './utils/debugModeUtil';
|
|
20
|
+
export { default as deletionRegistry } from './utils/deletionRegistry';
|
|
21
|
+
export { default as dropdownUtil } from './utils/garudaDropdownUtil';
|
|
22
|
+
export { default as logCollectorUtil } from './utils/logCollectorUtil';
|
|
23
|
+
export { default as reportUploader } from './utils/reportUploader';
|
|
24
|
+
export { default as requestRecorderUtil } from './utils/requestRecorderUtil';
|
|
25
|
+
export { default as screenshotRecorderUtil } from './utils/screenshotRecorderUtil';
|
|
26
|
+
export { default as setupMock } from './utils/setupMock';
|
|
27
|
+
export { default as lockService } from './services/lockService';
|
|
28
|
+
export { default as BasePage } from './pages/common/base.page';
|
|
29
|
+
export { default as loginPage } from './pages/common/login.page';
|
|
30
|
+
export * from './constants/common';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export default class BasePage{
|
|
2
|
+
async launchApplication(url){
|
|
3
|
+
console.log('Browser Maximize')
|
|
4
|
+
browser.maximizeWindow();
|
|
5
|
+
console.log('Triggered navigate to', url)
|
|
6
|
+
await browser.navigateTo(url)
|
|
7
|
+
console.log('Navigated to URL: ', url);
|
|
8
|
+
|
|
9
|
+
}
|
|
10
|
+
async quitApplication(){await browser.closeWindow()}
|
|
11
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import basePage from "./base.page"
|
|
2
|
+
import elementUtil from "../../utils/elementUtil"
|
|
3
|
+
import { maxLoginAttempts } from '../../constants/common';
|
|
4
|
+
import commands from "@rpii/wdio-commands"
|
|
5
|
+
|
|
6
|
+
class LoginPage extends basePage{
|
|
7
|
+
|
|
8
|
+
get isV2Login(){return process.env.V2_LOGIN === 'true'}
|
|
9
|
+
get inputUserName(){return this.isV2Login ? $('#login-username') : $('#login_user')}
|
|
10
|
+
get inputPassword(){return this.isV2Login ? $('#login-password') : $('#login_cred')}
|
|
11
|
+
get buttonLogin(){return this.isV2Login ? $('//button[.="Sign in"]') : $('#c-login-btn')}
|
|
12
|
+
get continueButton(){return $('//button[.="Continue"]')}
|
|
13
|
+
// Post-login readiness marker. The MFE Navigation Host replaced the old
|
|
14
|
+
// Dashboard (which rendered ExplorePosts → "Explore features") with NewDashboard,
|
|
15
|
+
// so the sidebar shell testid is the reliable signal the home shell has mounted.
|
|
16
|
+
// Old text kept as a backward-compatible fallback for pre-MFE environments.
|
|
17
|
+
get exploreFeatures(){return $('//*[@data-testid="cap-navigation-spa-sidebar"] | //*[text()="Explore features"]')}
|
|
18
|
+
|
|
19
|
+
async doLogin(url, username, password, loginTryCount=0){
|
|
20
|
+
try {
|
|
21
|
+
await browser.refresh();
|
|
22
|
+
await browser.pause(3000);
|
|
23
|
+
console.log('Browser launched')
|
|
24
|
+
await super.launchApplication(url)
|
|
25
|
+
if (loginTryCount > 0) {
|
|
26
|
+
let currentUrl = await browser.getUrl();
|
|
27
|
+
const isHome = currentUrl.includes("/home/ui");
|
|
28
|
+
if (isHome) {
|
|
29
|
+
try {
|
|
30
|
+
await (await this.exploreFeatures).waitForDisplayed({ timeout: 60000, interval: 5000 });
|
|
31
|
+
console.log('-----Logged In-----');
|
|
32
|
+
console.log('On retry login flow, /home/ui url and explore features text appeared after 60s probably, hence skipping entering username and password');
|
|
33
|
+
return;
|
|
34
|
+
} catch(e) {
|
|
35
|
+
console.log('proceeding with retry');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for(let i=0; i<=2; i++){
|
|
40
|
+
try{
|
|
41
|
+
await (await this.inputUserName).waitForDisplayed({ timeout: 60000 });
|
|
42
|
+
await this.inputUserName.waitForEnabled({ timeout: 30000, timeoutMsg:"thrown from dologin", interval:1000 })
|
|
43
|
+
break;
|
|
44
|
+
}catch(error){
|
|
45
|
+
console.log('Failed to load login page- Refreshing browser')
|
|
46
|
+
await browser.refresh();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await elementUtil.enterText( await this.inputUserName, username)
|
|
50
|
+
if (this.isV2Login) {
|
|
51
|
+
await this.continueButton.waitForEnabled({ timeout: 10000 })
|
|
52
|
+
await elementUtil.elementClick( await this.continueButton)
|
|
53
|
+
await this.inputPassword.waitForDisplayed({ timeout: 30000 })
|
|
54
|
+
}
|
|
55
|
+
await elementUtil.enterText( await this.inputPassword, password)
|
|
56
|
+
await this.buttonLogin.waitForEnabled({ timeout: 10000 })
|
|
57
|
+
await elementUtil.elementClick( await this.buttonLogin)
|
|
58
|
+
await browser.waitUntil(
|
|
59
|
+
async () => {
|
|
60
|
+
let currentUrl = await browser.getUrl();
|
|
61
|
+
return currentUrl.includes("/home/ui")
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
timeout: 60000,
|
|
65
|
+
timeoutMsg: 'expected url to include /home/ui',
|
|
66
|
+
interval: 1000,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
console.log('-----Browsers current url contains home/ui after login-----')
|
|
70
|
+
await (await this.exploreFeatures).waitForDisplayed({ timeout: 60000, interval: 5000 });
|
|
71
|
+
console.log('-----Logged In-----')
|
|
72
|
+
}
|
|
73
|
+
catch (error){
|
|
74
|
+
console.log('Throwed error from dologin, attempt #: ', loginTryCount, ', Error: ', error);
|
|
75
|
+
commands.logScreenshot("Failed to get the user logged in: "+error)
|
|
76
|
+
if (loginTryCount >= (maxLoginAttempts - 1)) {
|
|
77
|
+
console.log('Since login retry count exceeds 2. Exiting now.');
|
|
78
|
+
throw new Error("Failed to get the user logged in: "+error);
|
|
79
|
+
}
|
|
80
|
+
console.log('Retrying Login.. ');
|
|
81
|
+
await this.doLogin(url, username, password, ++loginTryCount);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async redirectHome(url){
|
|
86
|
+
try{
|
|
87
|
+
await super.launchApplication(url)
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
commands.logScreenshot("Exception logged: "+error)
|
|
91
|
+
throw new Error(error)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export default new LoginPage()
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
// A publish section holds the lock for ~1-3 minutes; anything older than this
|
|
5
|
+
// can only be a leftover from a run that died without releasing.
|
|
6
|
+
const STALE_LOCK_TTL_MS = 15 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
class LockService {
|
|
9
|
+
private baseLockDir: string;
|
|
10
|
+
|
|
11
|
+
constructor() {
|
|
12
|
+
// Lock/signal files default to <this dir>/locks, but can be redirected via LOCK_DIR so that
|
|
13
|
+
// independent suite runs (e.g. staging v3 + nightly v6 in parallel) don't share signal files
|
|
14
|
+
// like beeTemplateReady. Backward-compatible: unset LOCK_DIR keeps the original behaviour.
|
|
15
|
+
this.baseLockDir = process.env.LOCK_DIR
|
|
16
|
+
? path.resolve(process.env.LOCK_DIR)
|
|
17
|
+
: path.resolve(__dirname, 'locks');
|
|
18
|
+
if (!fs.existsSync(this.baseLockDir)) {
|
|
19
|
+
fs.mkdirSync(this.baseLockDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
initialize() {
|
|
24
|
+
this.cleanStaleLocks();
|
|
25
|
+
console.log("Lock service initialized.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private getLockFilePath(lockName: string): string {
|
|
29
|
+
return path.resolve(this.baseLockDir, `${lockName}.lock`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A lock is stale when its owner can no longer release it: the owning worker
|
|
34
|
+
* process is dead (identifier format Test-<pid>-<timestamp>), or the file is
|
|
35
|
+
* older than STALE_LOCK_TTL_MS (covers pid reuse and foreign identifiers).
|
|
36
|
+
* A stale lock is unreleasable by design (releaseLock enforces owner match),
|
|
37
|
+
* so every later run would burn all its acquire retries and proceed
|
|
38
|
+
* UNSERIALIZED — which is how parallel workers end up publishing over each
|
|
39
|
+
* other. Locks owned by a live process are never touched, so a concurrently
|
|
40
|
+
* running suite on this machine keeps its serialization.
|
|
41
|
+
*/
|
|
42
|
+
private isLockStale(lockFilePath: string): boolean {
|
|
43
|
+
let owner: string;
|
|
44
|
+
let mtimeMs: number;
|
|
45
|
+
try {
|
|
46
|
+
owner = fs.readFileSync(lockFilePath, 'utf8');
|
|
47
|
+
mtimeMs = fs.statSync(lockFilePath).mtimeMs;
|
|
48
|
+
} catch {
|
|
49
|
+
return false; // vanished meanwhile — nothing to clean
|
|
50
|
+
}
|
|
51
|
+
// Probe the owner's liveness FIRST — a live owner must never lose its lock,
|
|
52
|
+
// regardless of age (slow cluster, retried publish, debug SETTLE_MS pauses).
|
|
53
|
+
// The TTL only applies when the owner is dead, unknown, or a reused pid.
|
|
54
|
+
const match = /^Test-(\d+)-\d+$/.exec(owner.trim());
|
|
55
|
+
if (!match) return Date.now() - mtimeMs > STALE_LOCK_TTL_MS; // unknown format — TTL only
|
|
56
|
+
try {
|
|
57
|
+
process.kill(Number(match[1]), 0); // signal 0 = liveness probe, sends nothing
|
|
58
|
+
return false; // owner alive — never steal
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// ESRCH: no such process → stale. EPERM: exists but not ours → TTL fallback (pid reuse).
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
|
|
62
|
+
return Date.now() - mtimeMs > STALE_LOCK_TTL_MS;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private removeIfStale(lockFilePath: string): boolean {
|
|
69
|
+
if (!this.isLockStale(lockFilePath)) return false;
|
|
70
|
+
try {
|
|
71
|
+
fs.unlinkSync(lockFilePath);
|
|
72
|
+
console.log(`Removed stale lock '${path.basename(lockFilePath)}' (owner dead or TTL expired).`);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false; // another worker removed it first — fine
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Sweep all *.lock files in the lock dir, removing only stale ones. */
|
|
80
|
+
cleanStaleLocks(): void {
|
|
81
|
+
let lockFiles: string[];
|
|
82
|
+
try {
|
|
83
|
+
lockFiles = fs.readdirSync(this.baseLockDir).filter(file => file.endsWith('.lock'));
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const file of lockFiles) {
|
|
88
|
+
this.removeIfStale(path.resolve(this.baseLockDir, file));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async acquireLock({
|
|
93
|
+
testIdentifier,
|
|
94
|
+
lockName,
|
|
95
|
+
maxRetries = 10,
|
|
96
|
+
retryInterval = 1000
|
|
97
|
+
}: {
|
|
98
|
+
testIdentifier: string;
|
|
99
|
+
lockName: string;
|
|
100
|
+
maxRetries?: number;
|
|
101
|
+
retryInterval?: number;
|
|
102
|
+
}): Promise<boolean> {
|
|
103
|
+
|
|
104
|
+
const lockFilePath = this.getLockFilePath(lockName);
|
|
105
|
+
|
|
106
|
+
for (let retries = 0; ; retries++) {
|
|
107
|
+
// Self-heal locks left by crashed workers, then try an atomic
|
|
108
|
+
// create-exclusive: 'wx' fails with EEXIST if the file exists, so two
|
|
109
|
+
// waiters can never both believe they own the lock.
|
|
110
|
+
this.removeIfStale(lockFilePath);
|
|
111
|
+
try {
|
|
112
|
+
fs.writeFileSync(lockFilePath, testIdentifier, { flag: 'wx' });
|
|
113
|
+
console.log(`Lock for '${lockName}' acquired by ${testIdentifier}.`);
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
117
|
+
console.error(`Failed to acquire lock for '${lockName}':`, error);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let currentLockOwner = 'unknown';
|
|
123
|
+
try {
|
|
124
|
+
currentLockOwner = fs.readFileSync(lockFilePath, 'utf8');
|
|
125
|
+
} catch {
|
|
126
|
+
// released between attempts — next loop iteration will grab it
|
|
127
|
+
}
|
|
128
|
+
if (retries >= maxRetries) {
|
|
129
|
+
console.log(`Max retries reached. Lock for '${lockName}' is still held by ${currentLockOwner}.`);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
console.log(`Lock for '${lockName}' is already acquired by ${currentLockOwner}. Waiting for release... Retry ${retries + 1}/${maxRetries}`);
|
|
133
|
+
await browser.pause(retryInterval);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
releaseLock(testIdentifier: string, lockName: string): boolean {
|
|
138
|
+
const lockFilePath = this.getLockFilePath(lockName);
|
|
139
|
+
|
|
140
|
+
if (!fs.existsSync(lockFilePath)) {
|
|
141
|
+
console.log(`Lock for '${lockName}' is not acquired, nothing to release.`);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const currentLockOwner = fs.readFileSync(lockFilePath, 'utf8');
|
|
146
|
+
|
|
147
|
+
if (currentLockOwner !== testIdentifier) {
|
|
148
|
+
console.log(`Lock for '${lockName}' cannot be released. Current owner is ${currentLockOwner}, but ${testIdentifier} is trying to release it.`);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
fs.unlinkSync(lockFilePath);
|
|
154
|
+
console.log(`Lock for '${lockName}' released by ${testIdentifier}.`);
|
|
155
|
+
return true;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(`Failed to release lock for '${lockName}':`, error);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
isLocked(lockName: string): boolean {
|
|
163
|
+
const lockFilePath = this.getLockFilePath(lockName);
|
|
164
|
+
return fs.existsSync(lockFilePath);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Writes a signal file so another worker can detect completion.
|
|
169
|
+
* The file persists until the next test run overwrites it.
|
|
170
|
+
*/
|
|
171
|
+
writeSignal(signalName: string, value: string = "ready"): void {
|
|
172
|
+
const filePath = path.resolve(this.baseLockDir, `${signalName}.signal`);
|
|
173
|
+
fs.writeFileSync(filePath, value);
|
|
174
|
+
console.log(`Signal '${signalName}' written (value: ${value})`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Polls for a signal file written by another worker via writeSignal().
|
|
179
|
+
* Skips "pending" values — waits until a terminal value (ready/skipped/failed) is written.
|
|
180
|
+
* Returns the terminal signal value, or throws on timeout.
|
|
181
|
+
* NOTE: If a `clearSignal()` helper is added in the future, the read loop
|
|
182
|
+
* inside this method should use a try/catch on `fs.readFileSync` (catching
|
|
183
|
+
* `ENOENT`) instead of a preceding `fs.existsSync` check, to avoid a TOCTOU
|
|
184
|
+
* race condition.
|
|
185
|
+
*/
|
|
186
|
+
async waitForSignal(
|
|
187
|
+
signalName: string,
|
|
188
|
+
timeoutMs: number = 300000,
|
|
189
|
+
intervalMs: number = 5000,
|
|
190
|
+
): Promise<string> {
|
|
191
|
+
const filePath = path.resolve(this.baseLockDir, `${signalName}.signal`);
|
|
192
|
+
const deadline = Date.now() + timeoutMs;
|
|
193
|
+
let elapsed = 0;
|
|
194
|
+
|
|
195
|
+
while (Date.now() < deadline) {
|
|
196
|
+
if (fs.existsSync(filePath)) {
|
|
197
|
+
const value = fs.readFileSync(filePath, 'utf8');
|
|
198
|
+
if (value !== 'pending') {
|
|
199
|
+
console.log(`Signal '${signalName}' received (value: ${value}, waited ~${elapsed}ms)`);
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
console.log(`Signal '${signalName}' is pending — waiting for terminal value... (${elapsed}ms elapsed)`);
|
|
203
|
+
} else {
|
|
204
|
+
console.log(`Waiting for signal '${signalName}'... (${elapsed}ms elapsed, timeout: ${timeoutMs}ms)`);
|
|
205
|
+
}
|
|
206
|
+
await browser.pause(intervalMs);
|
|
207
|
+
elapsed += intervalMs;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
throw new Error(`Signal '${signalName}' not received within ${timeoutMs}ms`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export default new LockService();
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* antd-version detection for the antd v3/v5 -> v6 (new "unified" lib) migration.
|
|
3
|
+
*
|
|
4
|
+
* The CRM apps are being moved onto a new antd-v6 based component library. While
|
|
5
|
+
* the rollout is in progress, the SAME automation must run against BOTH the old
|
|
6
|
+
* and new UIs, and a revert of the environment from v6 back to v5/v3 must NOT
|
|
7
|
+
* require reverting any wdio selector. So every migrated locator keeps BOTH
|
|
8
|
+
* variants (legacy + v6) and we pick the right one at runtime.
|
|
9
|
+
*
|
|
10
|
+
* The top nav bar (org switcher) is shared by ALL apps, so its version is the
|
|
11
|
+
* single source of truth: we detect ONCE per worker (each wdio worker is its own
|
|
12
|
+
* process) and that one result applies to every app. The detection logic lives in
|
|
13
|
+
* detect() below — change only that method if the marker ever moves.
|
|
14
|
+
*/
|
|
15
|
+
class AntdVersionUtil {
|
|
16
|
+
// undefined = not yet detected; true/false = cached result for this worker.
|
|
17
|
+
private _isV6: boolean | undefined;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect whether the app under test is the new antd-v6 build, and cache it for
|
|
21
|
+
* the rest of the worker. Apps do not use antd directly — they render through
|
|
22
|
+
* the internal `cap-ui-library` CSS bundle, of which exactly ONE version (old
|
|
23
|
+
* or new=v6) is loaded per page. So the loaded `cap-ui-library` stylesheet IS
|
|
24
|
+
* the app's version. The new (v6) lib ships an `ant-row-legacy` selector that
|
|
25
|
+
* the old lib never does, so a single same-origin CSS scan is an exact,
|
|
26
|
+
* render-independent discriminator. The bundle is served from the same pod
|
|
27
|
+
* (same origin), so `cssRules` is always readable (no CORS).
|
|
28
|
+
*/
|
|
29
|
+
// Stylesheet whose presence means the internal UI lib (old OR new) has loaded.
|
|
30
|
+
private static readonly LIB_HREF_MARKER = 'cap-ui-library';
|
|
31
|
+
// Selector shipped only by the new (v6) lib; absent from the old lib's CSS.
|
|
32
|
+
private static readonly V6_CSS_MARKER = 'ant-row-legacy';
|
|
33
|
+
|
|
34
|
+
async detect(): Promise<boolean> {
|
|
35
|
+
// Runtime detection is DISABLED. selector() now always emits a combined
|
|
36
|
+
// v6-first selector, so we no longer need to know the version. This stub
|
|
37
|
+
// stays callable (wdio.conf's navigateTo hook and a campaigns getter call
|
|
38
|
+
// it) but does no browser work — no 30s waitUntil, no CSS scan. To restore
|
|
39
|
+
// detection, un-comment the block below and remove this early return.
|
|
40
|
+
return false;
|
|
41
|
+
/* ---- DISABLED detection logic (kept for easy restore) --------------
|
|
42
|
+
if (this._isV6 !== undefined) return this._isV6;
|
|
43
|
+
try {
|
|
44
|
+
// Wait until the page's CSS has loaded enough to decide the version.
|
|
45
|
+
// Some apps serve the internal UI-lib stylesheet under its own name
|
|
46
|
+
// (href contains `cap-ui-library`); others (e.g. badges) bundle it into
|
|
47
|
+
// hashed webpack chunks like `524.<hash>.css` where the href carries no
|
|
48
|
+
// recognizable marker. So we cannot gate solely on the href. The probe
|
|
49
|
+
// below succeeds as soon as we can DECIDE the version:
|
|
50
|
+
// - the lib stylesheet is present by href (fast path, unchanged), OR
|
|
51
|
+
// - the v6-only selector is already readable in any same-origin sheet
|
|
52
|
+
// (definitive v6 for chunked builds), OR
|
|
53
|
+
// - the document has fully loaded and at least one stylesheet is
|
|
54
|
+
// readable (legacy chunked build — no v6 marker will ever appear).
|
|
55
|
+
await browser.waitUntil(
|
|
56
|
+
async () =>
|
|
57
|
+
browser.execute(
|
|
58
|
+
(hrefMarker: string, cssMarker: string) => {
|
|
59
|
+
const sheets = Array.from(document.styleSheets);
|
|
60
|
+
if (
|
|
61
|
+
sheets.some((s) =>
|
|
62
|
+
(s.href || '').includes(hrefMarker),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
return true;
|
|
66
|
+
let readableSheet = false;
|
|
67
|
+
for (const sheet of sheets) {
|
|
68
|
+
let rules: CSSRuleList | null;
|
|
69
|
+
try {
|
|
70
|
+
rules = sheet.cssRules;
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!rules) continue;
|
|
75
|
+
readableSheet = true;
|
|
76
|
+
for (const rule of Array.from(rules)) {
|
|
77
|
+
const sel = (rule as CSSStyleRule)
|
|
78
|
+
.selectorText;
|
|
79
|
+
if (sel && sel.includes(cssMarker))
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
document.readyState === 'complete' &&
|
|
85
|
+
readableSheet
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
AntdVersionUtil.LIB_HREF_MARKER,
|
|
89
|
+
AntdVersionUtil.V6_CSS_MARKER,
|
|
90
|
+
),
|
|
91
|
+
{
|
|
92
|
+
timeout: 30000,
|
|
93
|
+
interval: 500,
|
|
94
|
+
timeoutMsg:
|
|
95
|
+
'stylesheets did not load for antd-version detection',
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
// Scan same-origin stylesheets for the v6-only selector. Returns on the
|
|
99
|
+
// first match, so v6 short-circuits; only legacy pages scan fully.
|
|
100
|
+
this._isV6 = await browser.execute((cssMarker: string) => {
|
|
101
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
102
|
+
let rules: CSSRuleList | null;
|
|
103
|
+
try {
|
|
104
|
+
rules = sheet.cssRules;
|
|
105
|
+
} catch {
|
|
106
|
+
continue; // cross-origin sheet, not readable — skip
|
|
107
|
+
}
|
|
108
|
+
if (!rules) continue;
|
|
109
|
+
for (const rule of Array.from(rules)) {
|
|
110
|
+
const sel = (rule as CSSStyleRule).selectorText;
|
|
111
|
+
if (sel && sel.includes(cssMarker)) return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}, AntdVersionUtil.V6_CSS_MARKER);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.log(`[antd-version] detection failed, assuming legacy: ${err}`);
|
|
118
|
+
this._isV6 = false;
|
|
119
|
+
}
|
|
120
|
+
console.log(
|
|
121
|
+
`[antd-version] app detected as ${this._isV6 ? 'antd v6 (new unified lib)' : 'legacy (antd v3/v5)'}`,
|
|
122
|
+
);
|
|
123
|
+
return this._isV6;
|
|
124
|
+
--------------------------------------------------------------------- */
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Navigation hook (wired into a global `navigateTo` override in wdio.conf).
|
|
129
|
+
* The antd version is a property of the PAGE that is loaded, not of the worker
|
|
130
|
+
* or of an app — the same app can be flipped between legacy and v6 part-way
|
|
131
|
+
* through a run, so caching the version for the worker's lifetime goes stale.
|
|
132
|
+
* Instead we re-detect on every navigation: clear the cache, then detect afresh
|
|
133
|
+
* against the page that just loaded. Every navigateTo target carries the shared
|
|
134
|
+
* top nav (app pages and the home/org/member-care shells all render the org
|
|
135
|
+
* switcher), so one of the detection markers is always present and the probe
|
|
136
|
+
* resolves quickly rather than waiting out the timeout.
|
|
137
|
+
*/
|
|
138
|
+
async onNavigate(): Promise<void> {
|
|
139
|
+
// No-op while detection is disabled. selector() emits both variants, so
|
|
140
|
+
// there is nothing to re-detect on navigation. Restore the two lines below
|
|
141
|
+
// if runtime detection is re-enabled in detect().
|
|
142
|
+
// this.reset();
|
|
143
|
+
// await this.detect();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Version flag for the FEW call sites that branch on the version directly
|
|
148
|
+
* (different interaction model, not just a different locator) rather than
|
|
149
|
+
* using selector()/element() pairs. Runtime detection is disabled and the
|
|
150
|
+
* environment under test is the new antd-v6 build, so this returns true so
|
|
151
|
+
* those branches take the v6 path. If the env is reverted to legacy, flip
|
|
152
|
+
* this back (or restore detection in detect() and return this._isV6).
|
|
153
|
+
*/
|
|
154
|
+
get isV6(): boolean | undefined {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build one combined selector that matches EITHER the v6 or the legacy
|
|
160
|
+
* variant, with the v6 variant placed FIRST. Runtime version detection is
|
|
161
|
+
* disabled (see the commented-out detect()/onNavigate() below) — instead of
|
|
162
|
+
* picking one variant we always emit both, so the same locator works on a v6
|
|
163
|
+
* page and on a legacy (reverted) page with no detection, no 30s waitUntil,
|
|
164
|
+
* and no per-navigation CSS scan.
|
|
165
|
+
*
|
|
166
|
+
* Every call site keeps its two selector STRINGS (legacy, v6) so a later
|
|
167
|
+
* cleanup is trivial: drop the legacy arg and this join.
|
|
168
|
+
*
|
|
169
|
+
* The two variants of a pair are always the same query language (verified:
|
|
170
|
+
* no mixed XPath/CSS pairs), so we join XPath with the `|` union and CSS with
|
|
171
|
+
* the `,` list. A page is either v6 or legacy, so only one side ever matches;
|
|
172
|
+
* ordering v6 first just expresses the preference.
|
|
173
|
+
*/
|
|
174
|
+
selector(legacy: string, v6: string): string {
|
|
175
|
+
const isXPath = (s: string) => /^\s*(\/|\.\/|\.\.\/|\()/.test(s);
|
|
176
|
+
return isXPath(v6) ? `${v6} | ${legacy}` : `${v6}, ${legacy}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Resolve a legacy/v6 selector pair to a live element using the picked one. */
|
|
180
|
+
element(legacy: string, v6: string) {
|
|
181
|
+
return $(this.selector(legacy, v6));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Test hook: clear the cached detection (not used in normal runs). */
|
|
185
|
+
reset(): void {
|
|
186
|
+
this._isV6 = undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default new AntdVersionUtil();
|