@globisnv/cypress-core 2.1.2 → 2.2.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 +13 -14
- package/biome-plugins/noUnnecessaryWaiting.grit +32 -0
- package/biome.json +2 -14
- package/dist/blueprints/GLO_departureScheduleTimes.js +3 -3
- package/dist/ci/sso-login.cy.d.ts +0 -0
- package/dist/ci/sso-login.cy.js +8 -0
- package/dist/commands/FOB_commands.d.ts +1 -1
- package/dist/commands/FOB_commands.js +6 -6
- package/dist/commands/GLO_commands.d.ts +0 -9
- package/dist/commands/GLO_commands.js +168 -60
- package/dist/commands/POR_commands.js +2 -2
- package/dist/config.js +13 -1
- package/dist/ssoSessionFile.d.ts +2 -0
- package/dist/ssoSessionFile.js +43 -0
- package/dist/totp.d.ts +2 -0
- package/dist/totp.js +54 -0
- package/dist/websocketDetector.js +1 -1
- package/package.json +3 -3
- package/template/biome.json +2 -1
- package/template/cypress.env.json.example +3 -2
- package/template/package.json +3 -3
- package/scripts/check-unnecessary-waiting.mjs +0 -119
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Core building blocks for Globis Cypress E2E tests.
|
|
4
4
|
|
|
5
|
-
This package holds everything that is *not* customer-specific: the shared Cypress config factory, the custom command library for the Servoy-based Globis solutions, the WebSocket-aware loading detector, the reusable data-entry blueprints, and the lint
|
|
5
|
+
This package holds everything that is *not* customer-specific: the shared Cypress config factory, the custom command library for the Servoy-based Globis solutions, the WebSocket-aware loading detector, the reusable data-entry blueprints, and the lint plugins. Customer repos install it, call `defineGlobisConfig()` in their `cypress.config.ts`, import `@globisnv/cypress-core/register` in their support file, and only keep their own specs and customer-specific commands locally.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -165,7 +165,7 @@ src/
|
|
|
165
165
|
├── commands/ # 6 generic command files (~128 custom commands)
|
|
166
166
|
├── apiMethods/ # IGS REST request/response helpers
|
|
167
167
|
└── plugins/ # prometheusWriter — SigV4-signed remote write to AWS AMP
|
|
168
|
-
|
|
168
|
+
biome-plugins/ # GritQL lint rules (shipped to consumers)
|
|
169
169
|
```
|
|
170
170
|
|
|
171
171
|
### Command families
|
|
@@ -221,10 +221,10 @@ Set via Cypress env (`config.env`, `CYPRESS_*`, or `--env`) unless noted.
|
|
|
221
221
|
| Key | Purpose |
|
|
222
222
|
| --- | --- |
|
|
223
223
|
| `globisUrl` | Base solution URL. `GLJ/GLS/GLT_login` rewrite the `/solution/<name>` segment off this. |
|
|
224
|
-
| `portalUrl`, `formBuilderUrl`, `
|
|
224
|
+
| `portalUrl`, `formBuilderUrl`, `ssoUrl` | Entry URLs for the portal, form builder and Okta login flows (`ssoUrl` was `oktaUrl`). With `loginType: sso` the provider follows from it: Okta when it contains `okta`, otherwise Keycloak (default `https://auth.globis.cloud`). |
|
|
225
225
|
| `username`, `username2` | Default and alternate login users. |
|
|
226
|
-
| `password`, `password2`, `api_key` | Secrets — kept node-side, never exposed to the browser. |
|
|
227
|
-
| `loginType
|
|
226
|
+
| `password`, `password2`, `api_key`, `otpSecret` | Secrets — kept node-side, never exposed to the browser. `otpSecret` is the base32 TOTP secret for Keycloak users with OTP. |
|
|
227
|
+
| `loginType` (`default`/`servoy`/`sso`), `ownerName`, `formBuilderStandalone`, `saveData` | Login/flow switches read by the commands. |
|
|
228
228
|
| `BUILD_NUMBER` | Used by `getOrCreateUuid()` to build a stable `CYP<build>` test-data prefix. |
|
|
229
229
|
| `extraParams` | Object of Jenkins-passed extras; `cypressWebsocketLoader` and `cypressWebsocketDebug` (`'true'`) live here. |
|
|
230
230
|
| `AMP_REMOTE_WRITE_URL`, `AMP_REGION` | Node `process.env` — Prometheus remote-write target and region. |
|
|
@@ -242,23 +242,22 @@ npm run lint # biome check .
|
|
|
242
242
|
npm run lint:fix # biome check . --write
|
|
243
243
|
```
|
|
244
244
|
|
|
245
|
-
Formatting is Biome ([biome.json](biome.json)): 4-space indent, single quotes, semicolons, no trailing commas, 180-column lines, LF endings.
|
|
245
|
+
Formatting is Biome ([biome.json](biome.json)): 4-space indent, single quotes, semicolons, no trailing commas, 180-column lines, LF endings. [src/biome.json](src/biome.json) extends it for `src/` only: it enables `noUnnecessaryWaiting` there and disables `noUnusedVariables` for `types.ts`, which is declaration-only. It lives in `src/` because consumers `extends` the root `biome.json`, and a plugin path in there would resolve against their repo.
|
|
246
246
|
|
|
247
|
-
### Shipped lint
|
|
247
|
+
### Shipped lint plugins
|
|
248
248
|
|
|
249
|
-
`
|
|
249
|
+
`biome-plugins/` is published with the package so consumer repos run the same rules over their specs as Biome [GritQL plugins](https://biomejs.dev/linter/plugins/). Their diagnostics show up in `biome check`, the editor and the CI linter like any built-in rule.
|
|
250
250
|
|
|
251
|
-
|
|
|
251
|
+
| Plugin | What it does |
|
|
252
252
|
| --- | --- |
|
|
253
|
-
| [
|
|
253
|
+
| [noUnnecessaryWaiting.grit](biome-plugins/noUnnecessaryWaiting.grit) | Fails on `cy.wait(<number>)`, including numeric constants/default params declared in an enclosing scope. Exempt a call with `// biome-ignore lint/plugin/noUnnecessaryWaiting: <reason>` on the line above. |
|
|
254
254
|
|
|
255
|
-
Wire it into the consumer repo
|
|
255
|
+
Wire it into the consumer repo's `biome.json` (the template already does). Biome resolves plugin paths from the consumer's root, not from an `extends`-ed config, so the path can't live in this package's `biome.json`:
|
|
256
256
|
|
|
257
257
|
```json
|
|
258
258
|
{
|
|
259
|
-
"
|
|
260
|
-
|
|
261
|
-
}
|
|
259
|
+
"extends": ["./node_modules/@globisnv/cypress-core/biome.json"],
|
|
260
|
+
"plugins": ["./node_modules/@globisnv/cypress-core/biome-plugins/noUnnecessaryWaiting.grit"]
|
|
262
261
|
}
|
|
263
262
|
```
|
|
264
263
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
language js
|
|
2
|
+
|
|
3
|
+
// A numeric `const`/`let`/`var` declared directly in this list of statements.
|
|
4
|
+
pattern numeric_decl($name) {
|
|
5
|
+
some JsVariableStatement(declaration=JsVariableDeclaration(declarators=some JsVariableDeclarator(id=$name, initializer=JsInitializerClause(expression=JsNumberLiteralExpression()))))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A parameter with a numeric default in this parameter list.
|
|
9
|
+
pattern numeric_param($name) { JsParameters(items=some JsFormalParameter(binding=$name, initializer=JsInitializerClause(expression=JsNumberLiteralExpression()))) }
|
|
10
|
+
|
|
11
|
+
// Only a scope's own statements/parameters count, so a same-named local in another function doesn't match.
|
|
12
|
+
`cy.wait($ms, $...)` as $call where {
|
|
13
|
+
or {
|
|
14
|
+
$ms <: JsNumberLiteralExpression(),
|
|
15
|
+
and {
|
|
16
|
+
$ms <: JsIdentifierExpression(),
|
|
17
|
+
$ms <: r"(.*)"($name),
|
|
18
|
+
$call <: within or {
|
|
19
|
+
JsFunctionBody(statements=numeric_decl($name)),
|
|
20
|
+
JsBlockStatement(statements=numeric_decl($name)),
|
|
21
|
+
JsModule(items=numeric_decl($name)),
|
|
22
|
+
JsScript(statements=numeric_decl($name)),
|
|
23
|
+
JsFunctionDeclaration(parameters=numeric_param($name)),
|
|
24
|
+
JsFunctionExpression(parameters=numeric_param($name)),
|
|
25
|
+
JsArrowFunctionExpression(parameters=numeric_param($name)),
|
|
26
|
+
JsMethodClassMember(parameters=numeric_param($name)),
|
|
27
|
+
JsMethodObjectMember(parameters=numeric_param($name))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
register_diagnostic(span=$call, message="Avoid arbitrary cy.wait(ms); prefer cy.intercept()/should() polling instead.")
|
|
32
|
+
}
|
package/biome.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.5.
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
|
|
3
3
|
"vcs": {
|
|
4
4
|
"enabled": true,
|
|
5
5
|
"clientKind": "git",
|
|
@@ -44,17 +44,5 @@
|
|
|
44
44
|
"noDoubleEquals": "error"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
}
|
|
48
|
-
"overrides": [
|
|
49
|
-
{
|
|
50
|
-
"includes": ["src/types.ts"],
|
|
51
|
-
"linter": {
|
|
52
|
-
"rules": {
|
|
53
|
-
"correctness": {
|
|
54
|
-
"noUnusedVariables": "off"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
]
|
|
47
|
+
}
|
|
60
48
|
}
|
|
@@ -3,11 +3,11 @@ const formName = 'ng$logistic_conn_schedule_times_dtl';
|
|
|
3
3
|
const addressFormName = 'ng$remark_dtl_logistic_conn_schedule_times';
|
|
4
4
|
const detailTabsName = 'tabs_70';
|
|
5
5
|
export const GLO_enterDepartureScheduleTimes = (obj) => {
|
|
6
|
-
//
|
|
6
|
+
// The times are Time-Box fields; each time zone is a field of its own, shown while Use time zones is on
|
|
7
7
|
cy.GLO_addInput([formName, 'departure_time'], obj.departureTime);
|
|
8
|
-
cy.GLO_addInput([formName, '
|
|
8
|
+
cy.GLO_addInput([formName, 'departure_timezone'], obj.departureTimezone);
|
|
9
9
|
cy.GLO_addInput([formName, 'arrival_time'], obj.arrivalTime);
|
|
10
|
-
cy.GLO_addInput([formName, '
|
|
10
|
+
cy.GLO_addInput([formName, 'arrival_timezone'], obj.arrivalTimezone);
|
|
11
11
|
typeIfPresent('arrival_relative_days', obj.arrivalRelativeDays);
|
|
12
12
|
typeIfPresent('embark_duration', obj.embarkDuration);
|
|
13
13
|
typeIfPresent('disembark_duration', obj.disembarkDuration);
|
|
File without changes
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// CI pre-step: logs in once so parallel runners on other machines can take over the SSO session (env ssoSessionFile).
|
|
3
|
+
// Run it with: cypress run --spec node_modules/@globisnv/cypress-core/dist/ci/sso-login.cy.js
|
|
4
|
+
describe('SSO login once', () => {
|
|
5
|
+
it('logs in and stores the IdP session for the other runners', () => {
|
|
6
|
+
cy.GLO_login();
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -29,7 +29,7 @@ declare global {
|
|
|
29
29
|
* @param {string} [company] - Optional parameter representing the company name.
|
|
30
30
|
* @param {boolean} [formBuilderStandalone] - Optional check if test uses standalone formbuilder or not.
|
|
31
31
|
*/
|
|
32
|
-
|
|
32
|
+
FOB_ssoLogin(company?: string, formBuilderStandalone?: boolean): void;
|
|
33
33
|
/**
|
|
34
34
|
* Command to add a new custom view.
|
|
35
35
|
*
|
|
@@ -8,8 +8,8 @@ Cypress.Commands.add('FOB_login', (company, formBuilderStandalone) => {
|
|
|
8
8
|
case 'servoy':
|
|
9
9
|
cy.POR_servoyLogin(company, formBuilderStandalone);
|
|
10
10
|
break;
|
|
11
|
-
case '
|
|
12
|
-
cy.
|
|
11
|
+
case 'sso':
|
|
12
|
+
cy.FOB_ssoLogin(company, formBuilderStandalone);
|
|
13
13
|
break;
|
|
14
14
|
case 'default':
|
|
15
15
|
cy.FOB_defaultLogin(company, formBuilderStandalone);
|
|
@@ -66,14 +66,14 @@ Cypress.Commands.add('FOB_defaultLogin', (company, formBuilderStandalone) => {
|
|
|
66
66
|
}
|
|
67
67
|
cy.get(`[data-cy="formbuilder${formBuilderStandalone ? '_standalone' : ''}.navbar_top"]`, { timeout: 120000 }).should('exist');
|
|
68
68
|
});
|
|
69
|
-
Cypress.Commands.add('
|
|
69
|
+
Cypress.Commands.add('FOB_ssoLogin', (company, formBuilderStandalone) => {
|
|
70
70
|
// Change the Url if necessary
|
|
71
71
|
const url = Cypress.expose('formBuilderUrl');
|
|
72
72
|
cy.visit(url);
|
|
73
|
-
// biome-ignore plugin: wait for okta redirect page load
|
|
73
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for okta redirect page load
|
|
74
74
|
cy.wait(2000);
|
|
75
75
|
cy.task('log', 'Redirect to okta url');
|
|
76
|
-
cy.origin(Cypress.expose('
|
|
76
|
+
cy.origin(Cypress.expose('ssoUrl'), () => {
|
|
77
77
|
cy.get('.o-form-input')
|
|
78
78
|
.find('[autocomplete="username"]')
|
|
79
79
|
.type(Cypress.expose('username'));
|
|
@@ -133,7 +133,7 @@ Cypress.Commands.add('FOB_deleteCustomViewItem', (name) => {
|
|
|
133
133
|
});
|
|
134
134
|
Cypress.Commands.add('FOB_dragComponentItem', (dragLocation, dropLocation) => {
|
|
135
135
|
cy.get(`[title="${dragLocation}"]`).realMouseDown({ button: 'left', position: 'center' }).realMouseMove(0, 10, { position: 'center' });
|
|
136
|
-
// biome-ignore plugin: wait for drag animation to settle before drop
|
|
136
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for drag animation to settle before drop
|
|
137
137
|
cy.wait(200);
|
|
138
138
|
cy.get(span).contains(dropLocation).realMouseMove(0, 0, { position: 'center' }).realMouseUp();
|
|
139
139
|
cy.GLO_checkLoadingIndicator();
|
|
@@ -65,15 +65,6 @@ declare global {
|
|
|
65
65
|
* If provided, the specified company will be selected during login.
|
|
66
66
|
*/
|
|
67
67
|
GLO_defaultloginExtraUser(usernameENV: string, passwordENV: string, company?: string): void;
|
|
68
|
-
/**
|
|
69
|
-
* Command to perform a login using Okta authentication.
|
|
70
|
-
*
|
|
71
|
-
* @param {string} [company] - Optional parameter representing the company name.
|
|
72
|
-
* If provided, it selects the specified company after login.
|
|
73
|
-
* @param {urlTypeType} [urlType] - This optional parameter is used to change the url solution parameter from 'online' to something else, e.g. 'pos'.
|
|
74
|
-
* @param {skipCheckFlag} [skipNavCheck] - This optional parameter is used to skip the navbar validation. Only use this in rare cases
|
|
75
|
-
*/
|
|
76
|
-
GLO_oktaLogin(company?: string, urlType?: urlTypeType, skipNavCheck?: skipCheckFlag): void;
|
|
77
68
|
/**
|
|
78
69
|
* Command to perform a logout.
|
|
79
70
|
*/
|
|
@@ -88,14 +88,14 @@ Cypress.Commands.add('GLO_login', (company, urlType) => {
|
|
|
88
88
|
case 'servoy':
|
|
89
89
|
cy.GLO_servoyLogin(company);
|
|
90
90
|
break;
|
|
91
|
-
case '
|
|
92
|
-
|
|
91
|
+
case 'sso':
|
|
92
|
+
ssoLogin(company, urlType);
|
|
93
93
|
break;
|
|
94
94
|
case 'default':
|
|
95
95
|
cy.GLO_defaultlogin(company, undefined, undefined, urlType);
|
|
96
96
|
break;
|
|
97
97
|
default:
|
|
98
|
-
throw new Error('Incorrect type for GLO_login. Available types: servoy,
|
|
98
|
+
throw new Error('Incorrect type for GLO_login. Available types: servoy, sso, default');
|
|
99
99
|
}
|
|
100
100
|
});
|
|
101
101
|
Cypress.Commands.add('GLO_defaultlogin', (company, usernameENV = 'username', passwordENV = 'password', urlType) => {
|
|
@@ -119,7 +119,7 @@ Cypress.Commands.add('GLO_defaultlogin', (company, usernameENV = 'username', pas
|
|
|
119
119
|
cy.GLO_checkLoadingIndicator();
|
|
120
120
|
cy.get('body').then(($body) => {
|
|
121
121
|
if ($body.find('.svy-message-dialog.active .window-title').text() === 'Locked record') {
|
|
122
|
-
// biome-ignore plugin: wait for locked record dialog choice to register
|
|
122
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for locked record dialog choice to register
|
|
123
123
|
cy.wait(15000);
|
|
124
124
|
cy.GLO_dialogChoice('!globis.label.yes!');
|
|
125
125
|
}
|
|
@@ -164,8 +164,12 @@ Cypress.Commands.add('GLO_defaultloginExtraUser', (usernameENV, passwordENV, com
|
|
|
164
164
|
}
|
|
165
165
|
cy.get('[data-cy="ng$main_form2.navbar_top"]', { timeout: 120000 }).should('exist');
|
|
166
166
|
});
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
// loginType sso: the URL of the identity provider, Globis Online's own Keycloak is the default
|
|
168
|
+
const ssoUrl = () => Cypress.expose('ssoUrl') || 'https://auth.globis.cloud';
|
|
169
|
+
// The provider follows from the URL. ponytail: add a provider here when a third one is supported
|
|
170
|
+
const ssoProvider = () => (new URL(ssoUrl()).hostname.includes('okta') ? 'okta' : 'keycloak');
|
|
171
|
+
const waitForWebSocketAfterSso = (idp = ssoUrl()) => {
|
|
172
|
+
cy.url({ timeout: 60000 }).should('not.include', new URL(idp).hostname);
|
|
169
173
|
cy.window({ timeout: 60000 }).should((win) => {
|
|
170
174
|
const w = win;
|
|
171
175
|
if (!w.wsMessages)
|
|
@@ -177,20 +181,8 @@ const waitForWebSocketAfterOkta = () => {
|
|
|
177
181
|
cy.task('log', `WebSocket spy confirmed connected, ${win.wsMessages?.messages.length} messages captured so far`);
|
|
178
182
|
});
|
|
179
183
|
};
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
let url = Cypress.expose('globisUrl');
|
|
183
|
-
if (urlType) {
|
|
184
|
-
const target = globisUrlTypes.includes(urlType) ? 'globis_online' : 'online';
|
|
185
|
-
url = url.replace(target, urlType);
|
|
186
|
-
}
|
|
187
|
-
// Stateless Okta login redirects server side before any Servoy client boots, so there is no
|
|
188
|
-
// websocket to wait for on the first visit — the connection is only asserted after the Okta
|
|
189
|
-
// round trip (see waitForWebSocketAfterOkta). The spy itself is attached globally on every
|
|
190
|
-
// AUT page load (see websocketDetector.ts), which covers this server-side redirect too.
|
|
191
|
-
cy.visit(url);
|
|
192
|
-
cy.task('log', 'Redirect to okta url');
|
|
193
|
-
cy.origin(Cypress.expose('oktaUrl'), () => {
|
|
184
|
+
const oktaIdpLogin = () => {
|
|
185
|
+
cy.origin(ssoUrl(), () => {
|
|
194
186
|
cy.get('.o-form-input', { timeout: 60000 })
|
|
195
187
|
.find('[autocomplete="username"]')
|
|
196
188
|
.clear()
|
|
@@ -205,11 +197,96 @@ Cypress.Commands.add('GLO_oktaLogin', (company, urlType, skipvNavCheck) => {
|
|
|
205
197
|
cy.task('log', 'Filled in password');
|
|
206
198
|
cy.get('[type="submit"]').click();
|
|
207
199
|
});
|
|
200
|
+
};
|
|
201
|
+
// Keycloak: username page, password page, then a one-time code page when the user has OTP (otpSecret is set)
|
|
202
|
+
const keycloakIdpLogin = () => {
|
|
203
|
+
// The code is made outside cy.origin: a task that waits for a free OTP window times out after 4s inside it.
|
|
204
|
+
// It is claimed right before the IdP pages, so it is still fresh when typed.
|
|
205
|
+
cy.env(['otpSecret']).then(({ otpSecret }) => {
|
|
206
|
+
if (otpSecret)
|
|
207
|
+
cy.task('totp', otpSecret, { log: false, timeout: 200000 }).then((code) => keycloakOrigin(code));
|
|
208
|
+
else
|
|
209
|
+
keycloakOrigin(undefined);
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const keycloakOrigin = (code) => {
|
|
213
|
+
cy.origin(ssoUrl(), { args: { code } }, ({ code }) => {
|
|
214
|
+
cy.get('input[name="username"]', { timeout: 60000 })
|
|
215
|
+
.clear()
|
|
216
|
+
.type(Cypress.expose('username'));
|
|
217
|
+
cy.task('log', 'Filled in username');
|
|
218
|
+
cy.get('#kc-login').click();
|
|
219
|
+
cy.env(['password']).then(({ password }) => {
|
|
220
|
+
cy.get('input[name="password"]', { timeout: 20000 }).type(password, { log: false });
|
|
221
|
+
cy.task('log', 'Filled in password');
|
|
222
|
+
cy.get('#kc-login').click();
|
|
223
|
+
if (code) {
|
|
224
|
+
cy.get('input[name="otp"]', { timeout: 20000 }).should('be.visible');
|
|
225
|
+
cy.get('input[name="otp"]').type(code, { log: false });
|
|
226
|
+
cy.task('log', 'Filled in one-time code');
|
|
227
|
+
cy.get('#kc-login').click();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
// One IdP login per run (cy.session, also across specs): every later call only revisits the app,
|
|
233
|
+
// which the IdP signs in silently, so no password/OTP is asked again.
|
|
234
|
+
// ponytail: no `validate` — an expired IdP session shows up as a login page, add a validate if that happens
|
|
235
|
+
const ssoLogin = (company, urlType, skipvNavCheck) => {
|
|
236
|
+
const provider = ssoProvider();
|
|
237
|
+
// Change the Url if necessary
|
|
238
|
+
let url = Cypress.expose('globisUrl');
|
|
239
|
+
if (urlType) {
|
|
240
|
+
const target = globisUrlTypes.includes(urlType) ? 'globis_online' : 'online';
|
|
241
|
+
url = url.replace(target, urlType);
|
|
242
|
+
}
|
|
243
|
+
cy.session(['sso', provider, Cypress.expose('username'), ssoUrl(), url], () => {
|
|
244
|
+
const file = Cypress.expose('ssoSessionFile');
|
|
245
|
+
const idpHost = new URL(ssoUrl()).hostname;
|
|
246
|
+
const isIdpCookie = (c) => idpHost === c.domain.replace(/^\./, '') || idpHost.endsWith(`.${c.domain.replace(/^\./, '')}`);
|
|
247
|
+
const login = () => {
|
|
248
|
+
// Stateless SSO login redirects server side before any Servoy client boots, so there is no
|
|
249
|
+
// websocket to wait for on the first visit — the connection is only asserted after the IdP
|
|
250
|
+
// round trip (see waitForWebSocketAfterSso). The spy itself is attached globally on every
|
|
251
|
+
// AUT page load (see websocketDetector.ts), which covers this server-side redirect too.
|
|
252
|
+
cy.visit(url);
|
|
253
|
+
cy.task('log', `Redirect to ${provider} url`);
|
|
254
|
+
if (provider === 'okta')
|
|
255
|
+
oktaIdpLogin();
|
|
256
|
+
else
|
|
257
|
+
keycloakIdpLogin();
|
|
258
|
+
// Let the app finish its login callback, then keep only the IdP cookies: the app's own client session
|
|
259
|
+
// does not survive a restore, so every call starts a fresh app client that the IdP signs in silently.
|
|
260
|
+
if (cypressWebsocketLoader)
|
|
261
|
+
waitForWebSocketAfterSso(ssoUrl());
|
|
262
|
+
else
|
|
263
|
+
cy.url({ timeout: 60000 }).should('not.include', idpHost);
|
|
264
|
+
cy.getAllCookies({ log: false }).then((all) => {
|
|
265
|
+
for (const c of all)
|
|
266
|
+
if (!isIdpCookie(c))
|
|
267
|
+
cy.clearCookie(c.name, { domain: c.domain, log: false });
|
|
268
|
+
if (file)
|
|
269
|
+
cy.task('ssoSessionWrite', { file, cookies: all.filter(isIdpCookie) }, { log: false });
|
|
270
|
+
});
|
|
271
|
+
cy.clearAllSessionStorage({ log: false });
|
|
272
|
+
};
|
|
273
|
+
// ssoSessionFile: another machine already logged in (CI pre-step), so take over its IdP cookies, no password/OTP here
|
|
274
|
+
if (!file)
|
|
275
|
+
return login();
|
|
276
|
+
cy.task('ssoSessionRead', file, { log: false }).then((saved) => {
|
|
277
|
+
if (!saved)
|
|
278
|
+
return login();
|
|
279
|
+
for (const c of saved) {
|
|
280
|
+
cy.setCookie(c.name, c.value, { domain: c.domain, path: c.path, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, expiry: c.expiry, log: false });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}, { cacheAcrossSpecs: true });
|
|
284
|
+
cy.visit(url);
|
|
208
285
|
if (company) {
|
|
209
286
|
cy.GLO_selectCompany(company);
|
|
210
287
|
}
|
|
211
288
|
if (cypressWebsocketLoader)
|
|
212
|
-
|
|
289
|
+
waitForWebSocketAfterSso(ssoUrl());
|
|
213
290
|
cy.task('log', 'Start navbar check');
|
|
214
291
|
if (!skipvNavCheck) {
|
|
215
292
|
const navbarSelectors = {
|
|
@@ -223,12 +300,12 @@ Cypress.Commands.add('GLO_oktaLogin', (company, urlType, skipvNavCheck) => {
|
|
|
223
300
|
}
|
|
224
301
|
else {
|
|
225
302
|
// This is in case the check is instable
|
|
226
|
-
// biome-ignore plugin: wait in case the check is instable
|
|
303
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait in case the check is instable
|
|
227
304
|
cy.wait(5000);
|
|
228
305
|
cy.GLO_checkLoadingIndicator();
|
|
229
306
|
}
|
|
230
307
|
cy.task('log', 'End navbar check');
|
|
231
|
-
}
|
|
308
|
+
};
|
|
232
309
|
Cypress.Commands.add('GLO_logout', () => {
|
|
233
310
|
cy.document({ log: false }).then((doc) => {
|
|
234
311
|
cy.GLO_getElement(['ng$main_form2', 'navbar_top']).find('.lucide-LogOut').click();
|
|
@@ -401,7 +478,7 @@ const waitForProgressBar = () => {
|
|
|
401
478
|
reject(new Error(`Timed out after ${Math.round((maxAttempts * pollInterval) / 1000)}s waiting for the progress bar to disappear`));
|
|
402
479
|
return;
|
|
403
480
|
}
|
|
404
|
-
// biome-ignore plugin: wait for next poll interval before checking again
|
|
481
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for next poll interval before checking again
|
|
405
482
|
cy.wait(pollInterval, { log: false }).then(() => {
|
|
406
483
|
log.set({ message: `Still waiting for the progress bar ${attempts}/${maxAttempts}` });
|
|
407
484
|
checkStatus(resolve, reject);
|
|
@@ -418,7 +495,7 @@ Cypress.Commands.add('GLO_checkLoadingIndicator', () => {
|
|
|
418
495
|
cy.WS_loadingCheck();
|
|
419
496
|
}
|
|
420
497
|
else {
|
|
421
|
-
// biome-ignore plugin: wait before checking loading indicator
|
|
498
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait before checking loading indicator
|
|
422
499
|
cy.wait(1000);
|
|
423
500
|
cy.get('loading-indicator', { timeout: loadingCheckTimeout }).should('not.exist');
|
|
424
501
|
waitForProgressBar();
|
|
@@ -723,16 +800,22 @@ Cypress.Commands.add('GLO_closeDashboard', () => {
|
|
|
723
800
|
Cypress.Commands.add('GLO_selectTab', (selector, tabName, openOrClose) => {
|
|
724
801
|
cy.GLO_getElement(selector)
|
|
725
802
|
.contains('[tabcontainer] .tab', tabName)
|
|
726
|
-
|
|
803
|
+
// The tab header is a div, so Cypress's `:disabled` actionability check doesn't apply: the click goes through
|
|
804
|
+
// and the server silently ignores it. Closing a disabled tab that is open fails too, because the close request
|
|
805
|
+
// goes through the same click on the same disabled tab, which the server ignores the same way. A tab can also
|
|
806
|
+
// be disabled only transiently (e.g. right after selecting a parent record), so this retries like any other
|
|
807
|
+
// Cypress assertion instead of failing on the first check.
|
|
808
|
+
.should(($tab) => {
|
|
727
809
|
const isOpenAttr = $tab[0].getAttribute('open'); // Attribute is a string 'true' or missing -> null
|
|
728
810
|
const isOpen = isOpenAttr === 'true'; // Convert to boolean
|
|
811
|
+
if (openOrClose !== isOpen && $tab[0].hasAttribute('disabled')) {
|
|
812
|
+
throw new Error(`Tab "${tabName}" in ${selector.join('.')} is disabled and cannot be ${openOrClose ? 'opened' : 'closed'}`);
|
|
813
|
+
}
|
|
814
|
+
})
|
|
815
|
+
.then(($tab) => {
|
|
816
|
+
const isOpenAttr = $tab[0].getAttribute('open');
|
|
817
|
+
const isOpen = isOpenAttr === 'true';
|
|
729
818
|
if (openOrClose !== isOpen) {
|
|
730
|
-
// The tab header is a div, so Cypress's `:disabled` actionability check doesn't apply: the click goes through
|
|
731
|
-
// and the server silently ignores it. Closing a disabled tab that is open fails too, because the close request
|
|
732
|
-
// goes through the same click on the same disabled tab, which the server ignores the same way.
|
|
733
|
-
if ($tab[0].hasAttribute('disabled')) {
|
|
734
|
-
throw new Error(`Tab "${tabName}" in ${selector.join('.')} is disabled and cannot be ${openOrClose ? 'opened' : 'closed'}`);
|
|
735
|
-
}
|
|
736
819
|
const action = openOrClose ? 'Opening' : 'Closing';
|
|
737
820
|
cy.log(`${action} tab ${tabName}`);
|
|
738
821
|
cy.wrap($tab).click();
|
|
@@ -804,7 +887,7 @@ Cypress.Commands.add('GLO_closeProgram', (topBottom, programName, tabNumber) =>
|
|
|
804
887
|
cy.GLO_log(` - Closed program tab ${programName}`);
|
|
805
888
|
});
|
|
806
889
|
Cypress.Commands.add('GLO_dialogChoice', (confirmation) => {
|
|
807
|
-
// biome-ignore plugin: wait for dialog choice body to render
|
|
890
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for dialog choice body to render
|
|
808
891
|
cy.wait(500);
|
|
809
892
|
cy.get('body').then(($body) => {
|
|
810
893
|
let CSSSearchStr;
|
|
@@ -994,21 +1077,44 @@ Cypress.Commands.add('GLO_filterGrid', (colId, selector, text, exactMatch = noEq
|
|
|
994
1077
|
}
|
|
995
1078
|
});
|
|
996
1079
|
if (filterType) {
|
|
1080
|
+
// The typed value needs to sync into the grid's filter model before the operator
|
|
1081
|
+
// menu opens, or the menu's own value field still reads empty and the operator
|
|
1082
|
+
// picker starts from a stale/default state (reproduced manually: opening the menu
|
|
1083
|
+
// too soon after typing shows the filter value field blank).
|
|
1084
|
+
cy.GLO_checkLoadingIndicator();
|
|
997
1085
|
cy.get('@filterHeaderCell').find('button').click();
|
|
998
|
-
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1086
|
+
// Selecting the operator in this popup is occasionally flaky in Cypress (the click
|
|
1087
|
+
// registers but the picker display doesn't update to the new operator - reproduced
|
|
1088
|
+
// manually in the live app and ruled out as a wrong-popup/stale-aria-controls issue;
|
|
1089
|
+
// the open+select sequence itself is correct). Self-heal by reopening and reselecting
|
|
1090
|
+
// instead of guessing at the exact cause of the dropped click.
|
|
1091
|
+
// A compound AND/OR filter always has a second condition row scaffolded in (even
|
|
1092
|
+
// when unused), each with its own operator picker - scope to .first() throughout so
|
|
1093
|
+
// this always targets the primary condition's picker instead of matching 2 elements.
|
|
1094
|
+
const selectFilterType = (attemptsLeft) => {
|
|
1095
|
+
cy.get('.ag-filter-menu .ag-filter-select .ag-picker-field-display').first().click();
|
|
1096
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: special dropdown for filter, doesn't work without small wait
|
|
1097
|
+
cy.wait(250);
|
|
1098
|
+
cy.get('.ag-filter-menu .ag-picker-expanded')
|
|
1099
|
+
.invoke('attr', 'aria-controls')
|
|
1100
|
+
.then((dropDownId) => {
|
|
1101
|
+
cy.get(`[id="${dropDownId}"]`)
|
|
1102
|
+
.should('be.visible')
|
|
1103
|
+
.within(() => {
|
|
1104
|
+
cy.get('.ag-list-item').contains(filterType).should('be.visible').parent().click();
|
|
1105
|
+
});
|
|
1008
1106
|
});
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1107
|
+
cy.GLO_checkAGLoader();
|
|
1108
|
+
cy.get('.ag-filter-menu .ag-filter-select .ag-picker-field-display')
|
|
1109
|
+
.first()
|
|
1110
|
+
.then(($el) => {
|
|
1111
|
+
if ($el.text().trim() !== filterType && attemptsLeft > 0) {
|
|
1112
|
+
selectFilterType(attemptsLeft - 1);
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
};
|
|
1116
|
+
selectFilterType(2);
|
|
1117
|
+
cy.get('.ag-filter-menu .ag-filter-select .ag-picker-field-display').first().should('contain.text', filterType);
|
|
1012
1118
|
cy.press(Cypress.Keyboard.Keys.ESC); // Close the filter menu
|
|
1013
1119
|
}
|
|
1014
1120
|
cy.GLO_checkLoadingIndicator();
|
|
@@ -1057,13 +1163,13 @@ const selectYear = (targetYear) => {
|
|
|
1057
1163
|
const currentYear = Number(currentDate.split(' ')[1]);
|
|
1058
1164
|
if (currentYear < targetYear) {
|
|
1059
1165
|
cy.GLO_getVisible('i.dx-icon.dx-icon-chevronright').last().click();
|
|
1060
|
-
// biome-ignore plugin: wait for year navigation click to register
|
|
1166
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for year navigation click to register
|
|
1061
1167
|
cy.wait(500);
|
|
1062
1168
|
selectYear(targetYear);
|
|
1063
1169
|
}
|
|
1064
1170
|
else if (currentYear > targetYear) {
|
|
1065
1171
|
cy.GLO_getVisible('i.dx-icon.dx-icon-chevronleft').last().click();
|
|
1066
|
-
// biome-ignore plugin: wait for year navigation click to register
|
|
1172
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for year navigation click to register
|
|
1067
1173
|
cy.wait(500);
|
|
1068
1174
|
selectYear(targetYear);
|
|
1069
1175
|
}
|
|
@@ -1083,13 +1189,13 @@ const selectMonth = (targetMonth) => {
|
|
|
1083
1189
|
throw new Error('Given month is invalid');
|
|
1084
1190
|
if (currenMonthNumber < targetMonth) {
|
|
1085
1191
|
cy.GLO_getVisible('i.dx-icon.dx-icon-chevronright').last().click();
|
|
1086
|
-
// biome-ignore plugin: wait for month navigation click to register
|
|
1192
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for month navigation click to register
|
|
1087
1193
|
cy.wait(500);
|
|
1088
1194
|
selectMonth(targetMonth);
|
|
1089
1195
|
}
|
|
1090
1196
|
else if (currenMonthNumber > targetMonth) {
|
|
1091
1197
|
cy.GLO_getVisible('i.dx-icon.dx-icon-chevronleft').last().click();
|
|
1092
|
-
// biome-ignore plugin: wait for month navigation click to register
|
|
1198
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for month navigation click to register
|
|
1093
1199
|
cy.wait(500);
|
|
1094
1200
|
selectMonth(targetMonth);
|
|
1095
1201
|
}
|
|
@@ -1452,8 +1558,8 @@ Cypress.Commands.add('GLO_validateCheckbox', (selector, value) => {
|
|
|
1452
1558
|
* It ensures that the AG Grid has finished loading.
|
|
1453
1559
|
*/
|
|
1454
1560
|
Cypress.Commands.add('GLO_checkAGLoader', () => {
|
|
1455
|
-
// biome-ignore plugin: wait because AGLoader is not instant
|
|
1456
|
-
cy.wait(1000);
|
|
1561
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait because AGLoader is not instant
|
|
1562
|
+
cy.wait(1000);
|
|
1457
1563
|
cy.get('.ag-loading', { timeout: 600000 }).should('not.exist');
|
|
1458
1564
|
});
|
|
1459
1565
|
Cypress.Commands.add('GLO_log', (logValue) => {
|
|
@@ -1629,7 +1735,7 @@ Cypress.Commands.add('GLO_checkNumberOfRowsInGrid', (selector, numberToValidate,
|
|
|
1629
1735
|
reject(new Error(`Timed out after ${Math.round((maxAttempts * pollInterval) / 100) / 10}s: row count was ${actual}, expected "${lengthCondition} ${numberToValidate}"`));
|
|
1630
1736
|
return;
|
|
1631
1737
|
}
|
|
1632
|
-
// biome-ignore plugin: wait for next poll interval before checking again
|
|
1738
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for next poll interval before checking again
|
|
1633
1739
|
cy.wait(pollInterval, { log: false }).then(() => {
|
|
1634
1740
|
log.set({ message: `Still polling ${actual} to ${lengthCondition} ${numberToValidate} ${attempts}/${maxAttempts}` });
|
|
1635
1741
|
checkStatus(resolve, reject);
|
|
@@ -1825,7 +1931,8 @@ Cypress.Commands.add('GLO_fillInfieldInGrid', (selector, rowFilter, columnId, va
|
|
|
1825
1931
|
.some((cell) => cell.textContent?.trim() === rowFilter))
|
|
1826
1932
|
.find(`[col-id="${columnId}"]`)
|
|
1827
1933
|
.click();
|
|
1828
|
-
|
|
1934
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: else both click and dblclick fire too fast
|
|
1935
|
+
cy.wait(500);
|
|
1829
1936
|
cy.GLO_getAllRowsInGrid()
|
|
1830
1937
|
.filter((_, el) => Cypress.$(el)
|
|
1831
1938
|
.find('[role="gridcell"]')
|
|
@@ -1838,7 +1945,8 @@ Cypress.Commands.add('GLO_fillInfieldInGrid', (selector, rowFilter, columnId, va
|
|
|
1838
1945
|
cy.GLO_getElement(selector).within(() => {
|
|
1839
1946
|
cy.GLO_clickRowInGrid(rowFilter, columnId);
|
|
1840
1947
|
});
|
|
1841
|
-
|
|
1948
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: else both click and dblclick fire too fast
|
|
1949
|
+
cy.wait(500);
|
|
1842
1950
|
cy.GLO_dblClickRowInGrid(row, selector, rowFilter, columnId, skipCheck);
|
|
1843
1951
|
}
|
|
1844
1952
|
cy.get(gridInputType === 'aggrid-texteditor-without-enter' ? gridInput : gridInputType, { timeout: 1000 }).should('exist');
|
|
@@ -1935,15 +2043,15 @@ Cypress.Commands.add('GLO_clickButtonInGrid', { prevSubject: ['optional', 'eleme
|
|
|
1935
2043
|
});
|
|
1936
2044
|
Cypress.Commands.add('GLO_oktaAlternativeLogin', (altUser, company) => {
|
|
1937
2045
|
cy.GLO_logout();
|
|
1938
|
-
// biome-ignore plugin: wait for Okta to process logout
|
|
1939
|
-
cy.wait(10000);
|
|
2046
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for Okta to process logout
|
|
2047
|
+
cy.wait(10000);
|
|
1940
2048
|
if (cypressWebsocketLoader) {
|
|
1941
2049
|
cy.on('window:before:load', (win) => {
|
|
1942
2050
|
attachWebSocketSpy(win);
|
|
1943
2051
|
});
|
|
1944
2052
|
}
|
|
1945
2053
|
if (altUser) {
|
|
1946
|
-
cy.origin(
|
|
2054
|
+
cy.origin(ssoUrl(), () => {
|
|
1947
2055
|
cy.get('.okta-container', { timeout: 120000 });
|
|
1948
2056
|
cy.get('.o-form-input', { timeout: 60000 })
|
|
1949
2057
|
.find('[autocomplete="username"]')
|
|
@@ -1962,7 +2070,7 @@ Cypress.Commands.add('GLO_oktaAlternativeLogin', (altUser, company) => {
|
|
|
1962
2070
|
cy.GLO_log(`Logged in as ${Cypress.expose('username2')}`);
|
|
1963
2071
|
}
|
|
1964
2072
|
else {
|
|
1965
|
-
cy.origin(
|
|
2073
|
+
cy.origin(ssoUrl(), () => {
|
|
1966
2074
|
cy.get('.okta-container', { timeout: 120000 });
|
|
1967
2075
|
cy.get('.o-form-input', { timeout: 60000 })
|
|
1968
2076
|
.find('[autocomplete="username"]')
|
|
@@ -1981,7 +2089,7 @@ Cypress.Commands.add('GLO_oktaAlternativeLogin', (altUser, company) => {
|
|
|
1981
2089
|
cy.GLO_log(`Logged in as ${Cypress.expose('username')}`);
|
|
1982
2090
|
}
|
|
1983
2091
|
if (cypressWebsocketLoader)
|
|
1984
|
-
|
|
2092
|
+
waitForWebSocketAfterSso();
|
|
1985
2093
|
cy.get('[data-cy$="ng$main_form2.navbar_top"]', { timeout: 120000 }).should('exist');
|
|
1986
2094
|
if (company)
|
|
1987
2095
|
cy.GLO_checkAndSelectCompany(company);
|
|
@@ -12,9 +12,9 @@ Cypress.Commands.add('POR_checkPortalSideMenuCorrect', (menuItems) => {
|
|
|
12
12
|
});
|
|
13
13
|
Cypress.Commands.add('POR_portalLogin', () => {
|
|
14
14
|
cy.visit(Cypress.expose('portalUrl'));
|
|
15
|
-
// biome-ignore plugin: wait for portal login redirect
|
|
15
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for portal login redirect
|
|
16
16
|
cy.wait(2000);
|
|
17
|
-
cy.origin(Cypress.expose('
|
|
17
|
+
cy.origin(Cypress.expose('ssoUrl'), () => {
|
|
18
18
|
cy.get('.o-form-input')
|
|
19
19
|
.find('[autocomplete="username"]')
|
|
20
20
|
.type(Cypress.expose('username'));
|
package/dist/config.js
CHANGED
|
@@ -45,7 +45,7 @@ export function defineGlobisConfig(overrides = {}) {
|
|
|
45
45
|
...overrides.e2e,
|
|
46
46
|
setupNodeEvents(on, config) {
|
|
47
47
|
// Sensitive keys never get exposed to the browser via config.expose.
|
|
48
|
-
const sensitiveEnvKeys = new Set(['password', 'password2', 'api_key', 'QLIKURLS', 'S3_KEY', 'S3_SECRET']);
|
|
48
|
+
const sensitiveEnvKeys = new Set(['password', 'password2', 'api_key', 'QLIKURLS', 'S3_KEY', 'S3_SECRET', 'otpSecret']);
|
|
49
49
|
for (const [key, val] of Object.entries(config.env)) {
|
|
50
50
|
if (!sensitiveEnvKeys.has(key))
|
|
51
51
|
config.expose[key] = val;
|
|
@@ -92,6 +92,18 @@ export function defineGlobisConfig(overrides = {}) {
|
|
|
92
92
|
});
|
|
93
93
|
return result;
|
|
94
94
|
},
|
|
95
|
+
// IdP session handed from one machine to the others (CI stashes the file), see ssoLogin in GLO_commands
|
|
96
|
+
async ssoSessionRead(file) {
|
|
97
|
+
return (await import('./ssoSessionFile.js')).readSsoSession(file);
|
|
98
|
+
},
|
|
99
|
+
async ssoSessionWrite({ file, cookies }) {
|
|
100
|
+
(await import('./ssoSessionFile.js')).writeSsoSession(file, cookies);
|
|
101
|
+
return null;
|
|
102
|
+
},
|
|
103
|
+
async totp(secret) {
|
|
104
|
+
const { nextTotp } = await import('./totp.js');
|
|
105
|
+
return nextTotp(secret);
|
|
106
|
+
},
|
|
95
107
|
...overrides.extraTasks
|
|
96
108
|
});
|
|
97
109
|
on('before:browser:launch', (browser, launchOptions) => {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_AGE_MS = 8 * 3600 * 1000;
|
|
4
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
5
|
+
// The file name comes from an env value, so its real location (parent symlinks resolved) must stay inside the
|
|
6
|
+
// project (cwd). The file itself is opened with O_NOFOLLOW so it can not be a symlink either.
|
|
7
|
+
const safePath = (file) => {
|
|
8
|
+
const resolved = path.resolve(file);
|
|
9
|
+
const parent = fs.realpathSync(path.dirname(resolved));
|
|
10
|
+
const target = path.join(parent, path.basename(resolved));
|
|
11
|
+
const relative = path.relative(fs.realpathSync(process.cwd()), target);
|
|
12
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
13
|
+
throw new Error(`ssoSessionFile must be a file inside the project, got: ${file}`);
|
|
14
|
+
// no O_NOFOLLOW (Windows): reject a symlinked file up front instead
|
|
15
|
+
if (!NOFOLLOW && fs.lstatSync(target, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
16
|
+
throw new Error('ssoSessionFile must not be a symlink');
|
|
17
|
+
return target;
|
|
18
|
+
};
|
|
19
|
+
export const readSsoSession = (file) => {
|
|
20
|
+
const target = safePath(file);
|
|
21
|
+
try {
|
|
22
|
+
const fd = fs.openSync(target, fs.constants.O_RDONLY | NOFOLLOW);
|
|
23
|
+
try {
|
|
24
|
+
const saved = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
25
|
+
return Date.now() - saved.savedAt < MAX_AGE_MS ? saved.cookies : null;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
fs.closeSync(fd);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
export const writeSsoSession = (file, cookies) => {
|
|
36
|
+
const fd = fs.openSync(safePath(file), fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | NOFOLLOW, 0o600);
|
|
37
|
+
try {
|
|
38
|
+
fs.writeSync(fd, JSON.stringify({ savedAt: Date.now(), cookies }));
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
fs.closeSync(fd);
|
|
42
|
+
}
|
|
43
|
+
};
|
package/dist/totp.d.ts
ADDED
package/dist/totp.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
// RFC 6238: HMAC-SHA1, 6 digits, 30 second period (Keycloak defaults, identical in every realm)
|
|
6
|
+
const PERIOD_MS = 30000;
|
|
7
|
+
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
8
|
+
const base32Decode = (secret) => {
|
|
9
|
+
const bytes = [];
|
|
10
|
+
let bits = 0;
|
|
11
|
+
let value = 0;
|
|
12
|
+
for (const char of secret.replace(/[\s=]/g, '').toUpperCase()) {
|
|
13
|
+
const index = BASE32.indexOf(char);
|
|
14
|
+
if (index < 0)
|
|
15
|
+
throw new Error('otpSecret is not valid base32');
|
|
16
|
+
value = (value << 5) | index;
|
|
17
|
+
bits += 5;
|
|
18
|
+
if (bits >= 8) {
|
|
19
|
+
bytes.push((value >>> (bits - 8)) & 255);
|
|
20
|
+
bits -= 8;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return Buffer.from(bytes);
|
|
24
|
+
};
|
|
25
|
+
export const totp = (secret, timeMs = Date.now()) => {
|
|
26
|
+
const counter = Buffer.alloc(8);
|
|
27
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(timeMs / PERIOD_MS)));
|
|
28
|
+
const hash = createHmac('sha1', base32Decode(secret)).update(counter).digest();
|
|
29
|
+
const offset = hash[hash.length - 1] & 15;
|
|
30
|
+
const code = (hash.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
|
31
|
+
return String(code).padStart(6, '0');
|
|
32
|
+
};
|
|
33
|
+
// Keycloak refuses a code twice. Parallel runners are separate processes, so a window is claimed with an
|
|
34
|
+
// exclusive marker file in the temp dir (atomic): a runner that finds its window taken waits for the next one.
|
|
35
|
+
// ponytail: same machine only, runners on other machines (CI agents) still need staggering
|
|
36
|
+
const claimWindow = (secret, window) => {
|
|
37
|
+
const id = createHash('sha256').update(secret).digest('hex').slice(0, 12);
|
|
38
|
+
try {
|
|
39
|
+
fs.writeFileSync(join(tmpdir(), `globis-otp-${id}-${window}`), '', { flag: 'wx' });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
export const nextTotp = async (secret) => {
|
|
47
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
48
|
+
const window = Math.floor(Date.now() / PERIOD_MS);
|
|
49
|
+
if (claimWindow(secret, window))
|
|
50
|
+
return totp(secret);
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, (window + 1) * PERIOD_MS - Date.now() + 500));
|
|
52
|
+
}
|
|
53
|
+
throw new Error('No free OTP window after 6 tries, too many runners share this user');
|
|
54
|
+
};
|
|
@@ -492,7 +492,7 @@ Cypress.Commands.add('WS_loadingCheck', (options) => {
|
|
|
492
492
|
}
|
|
493
493
|
win.lockedRecordRetries = retries + 1;
|
|
494
494
|
debugLog(`🔍 Locked record dialog detected (attempt ${win.lockedRecordRetries}/3), waiting 60s before handling`);
|
|
495
|
-
// biome-ignore plugin: <internal> Cypress wait for locked record dialog handling
|
|
495
|
+
// biome-ignore lint/plugin/noUnnecessaryWaiting: <internal> Cypress wait for locked record dialog handling
|
|
496
496
|
cy.wait(LOCKED_RECORD_WAIT_MS).then(() => {
|
|
497
497
|
if (!lockedRecordDialogHandled) {
|
|
498
498
|
lockedRecordDialogHandled = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@globisnv/cypress-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Core building blocks for Globis Cypress E2E tests",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
36
|
-
"
|
|
36
|
+
"biome-plugins",
|
|
37
37
|
"bin",
|
|
38
38
|
"template",
|
|
39
39
|
"biome.json"
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"uuid": "^14.0.1"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@biomejs/biome": "^2.5.
|
|
60
|
+
"@biomejs/biome": "^2.5.15",
|
|
61
61
|
"@types/node": "^26.1.2",
|
|
62
62
|
"cypress": "^16.1.0",
|
|
63
63
|
"cypress-real-events": "^1.15.1",
|
package/template/biome.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.5.
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
|
|
3
3
|
"extends": ["./node_modules/@globisnv/cypress-core/biome.json"],
|
|
4
|
+
"plugins": ["./node_modules/@globisnv/cypress-core/biome-plugins/noUnnecessaryWaiting.grit"],
|
|
4
5
|
"files": {
|
|
5
6
|
"includes": ["!cypress/screenshots", "!cypress/videos", "!cypress/downloads", "!cypress/reports", "!cypress.env*.json"]
|
|
6
7
|
}
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// Delete these comments
|
|
3
3
|
|
|
4
4
|
{
|
|
5
|
-
"loginType": "<default/servoy/
|
|
5
|
+
"loginType": "<default/servoy/sso>",
|
|
6
6
|
"username": "<enter your username here>",
|
|
7
7
|
"password": "<enter your password here>",
|
|
8
8
|
"username2": "<optional - alternate login user, used by tests that switch users>",
|
|
9
9
|
"password2": "<optional - password for username2>",
|
|
10
10
|
|
|
11
11
|
"globisUrl": "<enter your globisUrl here>",
|
|
12
|
-
"
|
|
12
|
+
"ssoUrl": "<optional - IdP URL, only used when loginType is sso: Okta when it contains okta, otherwise Keycloak. Default https://auth.globis.cloud>",
|
|
13
|
+
"otpSecret": "<optional - base32 TOTP secret, only used when loginType is sso with Keycloak and the user has OTP>",
|
|
13
14
|
"ownerName": "<optional - only used for a local Servoy instance>",
|
|
14
15
|
|
|
15
16
|
"api_key": "<optional - only used when a command needs an API key>",
|
package/template/package.json
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
"typescript": "^7.0.2"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@biomejs/biome": "^2.5.
|
|
12
|
+
"@biomejs/biome": "^2.5.15",
|
|
13
13
|
"@types/node": "^26.1.2",
|
|
14
14
|
"cypress": "^16.0.0"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"lint": "biome check .
|
|
18
|
-
"lint:fix": "biome check . --write
|
|
17
|
+
"lint": "biome check .",
|
|
18
|
+
"lint:fix": "biome check . --write",
|
|
19
19
|
"typescript": "tsc --noEmit --skipLibCheck",
|
|
20
20
|
"open": "cypress open --e2e --browser chrome",
|
|
21
21
|
"record": "node ./cypress/support/record.mjs"
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Standalone replacement for the biome-plugins/no-unnecessary-waiting.grit plugin.
|
|
3
|
-
//
|
|
4
|
-
// Biome's GritQL plugins (2.5.7) have no suppression mechanism - a
|
|
5
|
-
// register_diagnostic() call either fires or it doesn't, with no per-line
|
|
6
|
-
// ignore comment support (confirmed: `biome lint --suppress` writes nothing
|
|
7
|
-
// for plugin-sourced diagnostics). Blocking on cy.wait(<number>) while still
|
|
8
|
-
// allowing a documented exception needs code that can check for a preceding
|
|
9
|
-
// comment itself, so this is a plain AST walk instead of a Grit plugin.
|
|
10
|
-
//
|
|
11
|
-
// Flags cy.wait(<number literal>[, options]) unless the line directly above
|
|
12
|
-
// the call has a `// biome-ignore plugin: <reason>` comment.
|
|
13
|
-
|
|
14
|
-
import path from 'node:path';
|
|
15
|
-
import * as ts from 'typescript/unstable/ast';
|
|
16
|
-
import { API } from 'typescript/unstable/sync';
|
|
17
|
-
|
|
18
|
-
const ROOT = process.cwd(); // invoking repo's root, not this script's install location
|
|
19
|
-
const TSCONFIG = 'tsconfig.json';
|
|
20
|
-
const IGNORE_COMMENT = /^\s*\/\/\s*biome-ignore\s+plugin:/;
|
|
21
|
-
|
|
22
|
-
function walkAll(node, visit) {
|
|
23
|
-
visit(node);
|
|
24
|
-
node.forEachChild((child) => walkAll(child, visit));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function isIgnored(sourceFile, node) {
|
|
28
|
-
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line;
|
|
29
|
-
if (line === 0) return false;
|
|
30
|
-
const lineStarts = sourceFile.getLineStarts();
|
|
31
|
-
const prevLineText = sourceFile.text.slice(lineStarts[line - 1], lineStarts[line]);
|
|
32
|
-
return IGNORE_COMMENT.test(prevLineText);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// A cy.wait(x) call is just as arbitrary when x is a const/default-param alias for a
|
|
36
|
-
// numeric literal (e.g. `const serverTimeout = 5000`) as when it's inlined. Resolve by
|
|
37
|
-
// walking up to the nearest enclosing block/function and looking for a declaration of
|
|
38
|
-
// that name among its statements/parameters - not a file-wide name search, since two
|
|
39
|
-
// unrelated functions can each declare their own same-named local (e.g. `pollInterval`).
|
|
40
|
-
function resolveNumericAlias(identifier) {
|
|
41
|
-
const name = identifier.text;
|
|
42
|
-
for (let scope = identifier.parent; scope; scope = scope.parent) {
|
|
43
|
-
if (ts.isFunctionLikeDeclaration(scope)) {
|
|
44
|
-
for (const param of scope.parameters) {
|
|
45
|
-
if (ts.isIdentifier(param.name) && param.name.text === name && param.initializer && ts.isNumericLiteral(param.initializer)) {
|
|
46
|
-
return param.initializer.text;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (ts.isBlock(scope) || ts.isSourceFile(scope)) {
|
|
51
|
-
const statements = scope.statements;
|
|
52
|
-
for (const statement of statements) {
|
|
53
|
-
if (!ts.isVariableStatement(statement)) continue;
|
|
54
|
-
for (const decl of statement.declarationList.declarations) {
|
|
55
|
-
if (ts.isIdentifier(decl.name) && decl.name.text === name && decl.initializer && ts.isNumericLiteral(decl.initializer)) {
|
|
56
|
-
return decl.initializer.text;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function checkSourceFile(sourceFile) {
|
|
66
|
-
const violations = [];
|
|
67
|
-
walkAll(sourceFile, (node) => {
|
|
68
|
-
if (!ts.isCallExpression(node)) return;
|
|
69
|
-
const callee = node.expression;
|
|
70
|
-
if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.expression) || callee.expression.text !== 'cy') return;
|
|
71
|
-
if (callee.name.text !== 'wait') return;
|
|
72
|
-
|
|
73
|
-
const msArg = node.arguments[0];
|
|
74
|
-
if (!msArg) return;
|
|
75
|
-
const isDirectLiteral = ts.isNumericLiteral(msArg);
|
|
76
|
-
const isNumericAlias = ts.isIdentifier(msArg) && resolveNumericAlias(msArg) !== undefined;
|
|
77
|
-
if (!isDirectLiteral && !isNumericAlias) return;
|
|
78
|
-
if (isIgnored(sourceFile, node)) return;
|
|
79
|
-
|
|
80
|
-
violations.push({
|
|
81
|
-
file: sourceFile.fileName,
|
|
82
|
-
line: sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
return violations;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function main() {
|
|
89
|
-
const api = new API({ cwd: ROOT });
|
|
90
|
-
try {
|
|
91
|
-
const snapshot = api.updateSnapshot({ openProjects: [TSCONFIG] });
|
|
92
|
-
const project = snapshot.getProject(TSCONFIG);
|
|
93
|
-
|
|
94
|
-
const cypressRoot = path.join(ROOT, 'cypress') + path.sep;
|
|
95
|
-
const fileNames = project.rootFiles.filter((f) => f.startsWith(cypressRoot) && f.endsWith('.ts') && !f.endsWith('.d.ts'));
|
|
96
|
-
|
|
97
|
-
const allViolations = [];
|
|
98
|
-
for (const fileName of fileNames) {
|
|
99
|
-
const sourceFile = project.program.getSourceFile(fileName);
|
|
100
|
-
if (!sourceFile) continue;
|
|
101
|
-
allViolations.push(...checkSourceFile(sourceFile));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
for (const v of allViolations) {
|
|
105
|
-
console.log(
|
|
106
|
-
`${path.relative(ROOT, v.file)}:${v.line} Avoid arbitrary cy.wait(ms); prefer cy.intercept()/should() polling instead. (add "// biome-ignore plugin: <reason>" above the line if intentional)`
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (allViolations.length > 0) {
|
|
111
|
-
console.log(`\n${allViolations.length} unguarded cy.wait(ms) call(s) found.`);
|
|
112
|
-
process.exit(1);
|
|
113
|
-
}
|
|
114
|
-
} finally {
|
|
115
|
-
api.close();
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
main();
|