@conduction/nextcloud-vue 2.1.0-vue3.10 → 2.1.0-vue3.11
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 +18 -7
- package/eslint/index.d.ts +11 -1
- package/eslint/index.js +57 -14
- package/package.json +1 -1
- package/testing/index.js +4 -0
- package/testing/playwright.d.ts +70 -5
- package/testing/playwright.js +377 -58
package/README.md
CHANGED
|
@@ -209,8 +209,9 @@ Two things every consuming app used to reimplement are solved once here.
|
|
|
209
209
|
### `@conduction/nextcloud-vue/eslint`
|
|
210
210
|
|
|
211
211
|
The fleet's Vue 3 flat-config ESLint preset. Arms the whole
|
|
212
|
-
`vue/no-deprecated-*` family at `error`, sets
|
|
213
|
-
`languageOptions` and `languageOptions.parserOptions
|
|
212
|
+
`vue/no-deprecated-*` family at `error`, sets `ecmaVersion: 'latest'` on both
|
|
213
|
+
`languageOptions` and `languageOptions.parserOptions` (never a pinned year — a
|
|
214
|
+
shared preset that pins can only ever *lower* the app adopting it), wires
|
|
214
215
|
`parserOptions.parser` in vue-eslint-parser's **object** form, and configures
|
|
215
216
|
`vue/v-on-event-hyphenation` with `ignore: ['update:modelValue']`.
|
|
216
217
|
|
|
@@ -230,17 +231,27 @@ why a Vue 2 lint config left four silent memory leaks in a migrated app.
|
|
|
230
231
|
### `@conduction/nextcloud-vue/testing/playwright`
|
|
231
232
|
|
|
232
233
|
Dependency-free e2e helpers for the overlays and bundle constraints this
|
|
233
|
-
library imposes: `dismissFirstVisitOverlays`, `seedSupportDialogSeen`,
|
|
234
|
-
component-tree accessor that works
|
|
235
|
-
`__VUE_PROD_DEVTOOLS__ = false`.
|
|
234
|
+
library imposes: `dismissFirstVisitOverlays`, `seedSupportDialogSeen`,
|
|
235
|
+
`appDialog`, `retireFirstRunWizard`, and a component-tree accessor that works
|
|
236
|
+
against a production bundle with `__VUE_PROD_DEVTOOLS__ = false`.
|
|
236
237
|
|
|
237
238
|
```js
|
|
238
|
-
|
|
239
|
+
// In a global-setup, seed the CONTEXT with explicit ids so the flag rides in
|
|
240
|
+
// storageState. `'*'` reads back correctly in the page and persists NOTHING,
|
|
241
|
+
// so the helpers refuse that combination outright.
|
|
242
|
+
await seedFirstVisitOverlaysSeen(context, 'openbuild') // also covers openbuild-<slug>
|
|
243
|
+
|
|
244
|
+
const page = await context.newPage()
|
|
239
245
|
await page.goto('/apps/openbuild/')
|
|
246
|
+
await retireFirstRunWizard(page) // NC's own overlay, retired server-side
|
|
247
|
+
|
|
248
|
+
// `appDialog()` never matches NC/nc-vue chrome, so a click that missed cannot
|
|
249
|
+
// pass against a modal the spec never opened.
|
|
250
|
+
await expect(appDialog(page)).toBeVisible()
|
|
240
251
|
```
|
|
241
252
|
|
|
242
253
|
See [docs/testing/e2e-helpers.md](docs/testing/e2e-helpers.md) — including the
|
|
243
|
-
nested-`CnAppRoot` caveat.
|
|
254
|
+
nested-`CnAppRoot` caveat and the `'*'`/`storageState` trap.
|
|
244
255
|
|
|
245
256
|
## Development
|
|
246
257
|
|
package/eslint/index.d.ts
CHANGED
|
@@ -13,9 +13,19 @@
|
|
|
13
13
|
/** A single ESLint flat-config entry. */
|
|
14
14
|
export type CnFlatConfigEntry = Record<string, unknown>
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The ECMAScript syntax FLOOR every Conduction app may rely on, as a number —
|
|
18
|
+
* for tooling that refuses the `'latest'` string. NOT the value the preset
|
|
19
|
+
* sets; see {@link ECMA_LANGUAGE_LEVEL}.
|
|
20
|
+
*/
|
|
17
21
|
export const ECMA_VERSION: number
|
|
18
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The level the preset actually configures: `'latest'`. A shared preset that
|
|
25
|
+
* pins a year can only ever LOWER a consumer.
|
|
26
|
+
*/
|
|
27
|
+
export const ECMA_LANGUAGE_LEVEL: 'latest'
|
|
28
|
+
|
|
19
29
|
/** `parserOptions` for `<script>` blocks in `.vue` files (object-form parser). */
|
|
20
30
|
export const vueSfcParserOptions: Record<string, unknown>
|
|
21
31
|
|
package/eslint/index.js
CHANGED
|
@@ -35,10 +35,12 @@
|
|
|
35
35
|
* (`no-deprecated-delete-set`, `no-deprecated-model-definition`) are NOT in
|
|
36
36
|
* that preset, and because an explicit list is what makes the guarantee
|
|
37
37
|
* auditable — `--print-config` shows them by name.
|
|
38
|
-
* 2.
|
|
39
|
-
* `languageOptions.parserOptions`, so
|
|
40
|
-
* `context.parserOptions`) can parse
|
|
41
|
-
* coalescing and spread instead of inventing
|
|
38
|
+
* 2. `ecmaVersion: 'latest'` / `sourceType: 'module'`, set on BOTH
|
|
39
|
+
* `languageOptions` and `languageOptions.parserOptions`, so
|
|
40
|
+
* `eslint-plugin-import` (which reads `context.parserOptions`) can parse
|
|
41
|
+
* optional chaining, nullish coalescing and spread instead of inventing
|
|
42
|
+
* warnings about them. `'latest'` and not a pinned year: a shared preset
|
|
43
|
+
* that pins can only ever LOWER a consumer — see {@link ECMA_LANGUAGE_LEVEL}.
|
|
42
44
|
* 3. `vue/v-on-event-hyphenation` configured with
|
|
43
45
|
* `ignore: ['update:modelValue']` — see the block comment on the rule.
|
|
44
46
|
* 4. `parserOptions.parser` in vue-eslint-parser's documented OBJECT form,
|
|
@@ -102,18 +104,58 @@ const pluginVue = require('eslint-plugin-vue')
|
|
|
102
104
|
const vueParser = require('vue-eslint-parser')
|
|
103
105
|
|
|
104
106
|
/**
|
|
105
|
-
* The ECMAScript
|
|
107
|
+
* The ECMAScript syntax FLOOR every Conduction app may rely on.
|
|
106
108
|
*
|
|
107
|
-
* 2022 is the
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
* 2022 is the level that makes `?.`, `??`, `??=`, class fields, private methods
|
|
110
|
+
* and static blocks parseable. It is deliberately a named export: an app that
|
|
111
|
+
* must reconfigure a parser of its own and needs a NUMBER (some third-party
|
|
112
|
+
* tooling refuses the `'latest'` string) should reuse this constant instead of
|
|
113
|
+
* re-guessing one — guessing is how `ecmaVersion: 6` survived a Vue 3 migration
|
|
114
|
+
* and produced 20 phantom `import/*` warnings.
|
|
115
|
+
*
|
|
116
|
+
* It is a FLOOR, not the value the preset sets. See
|
|
117
|
+
* {@link ECMA_LANGUAGE_LEVEL}.
|
|
112
118
|
*
|
|
113
119
|
* @type {number}
|
|
114
120
|
*/
|
|
115
121
|
const ECMA_VERSION = 2022
|
|
116
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The ECMAScript level the preset actually configures — `'latest'`, never a
|
|
125
|
+
* pinned year.
|
|
126
|
+
*
|
|
127
|
+
* READ THIS BEFORE PINNING A NUMBER HERE AGAIN.
|
|
128
|
+
*
|
|
129
|
+
* A shared preset that pins a year can only ever LOWER a consumer. openconnector
|
|
130
|
+
* adopted this preset over a config that carried a top-level
|
|
131
|
+
* `ecmaVersion: 'latest'`, and the adoption silently downgraded it to 2022. It
|
|
132
|
+
* was harmless in that repository — but the harm is not hypothetical, it is the
|
|
133
|
+
* SAME failure the pin was introduced to fix: openconnector's older
|
|
134
|
+
* `ecmaVersion: 6` left `eslint-plugin-import` unable to parse `?.`, `??` and
|
|
135
|
+
* object spread, and it manufactured 20 warnings about perfectly valid code.
|
|
136
|
+
*
|
|
137
|
+
* Measured against this repository's ESLint (8.57 / espree 9.6), with the
|
|
138
|
+
* ES2024 `v` (unicodeSets) regexp flag as the probe:
|
|
139
|
+
*
|
|
140
|
+
* ```
|
|
141
|
+
* ecmaVersion: 2022 → FATAL "Parsing error: Invalid regular expression flag"
|
|
142
|
+
* ecmaVersion: 'latest' → clean
|
|
143
|
+
* ```
|
|
144
|
+
*
|
|
145
|
+
* A parse error is not a soft downgrade: ESLint reports a `fatal` message and
|
|
146
|
+
* every other rule on that file is skipped, so the deprecation gate this preset
|
|
147
|
+
* exists to arm goes SILENT on exactly the files using modern syntax.
|
|
148
|
+
*
|
|
149
|
+
* `'latest'` is ESLint's own supported spelling for "whatever this ESLint can
|
|
150
|
+
* parse" and moves forward with the consumer's toolchain instead of against it.
|
|
151
|
+
* A consumer that genuinely wants a pin can spread its own layer after the
|
|
152
|
+
* preset — flat config's last-wins ordering makes that a one-liner, and it is
|
|
153
|
+
* the consumer's call to make, not the shared preset's.
|
|
154
|
+
*
|
|
155
|
+
* @type {string}
|
|
156
|
+
*/
|
|
157
|
+
const ECMA_LANGUAGE_LEVEL = 'latest'
|
|
158
|
+
|
|
117
159
|
/**
|
|
118
160
|
* Resolve an optional module path, returning `null` when it is not installed.
|
|
119
161
|
*
|
|
@@ -164,7 +206,7 @@ const espreePath = resolveOptional('espree')
|
|
|
164
206
|
* @type {object}
|
|
165
207
|
*/
|
|
166
208
|
const vueSfcParserOptions = {
|
|
167
|
-
ecmaVersion:
|
|
209
|
+
ecmaVersion: ECMA_LANGUAGE_LEVEL,
|
|
168
210
|
sourceType: 'module',
|
|
169
211
|
requireConfigFile: false,
|
|
170
212
|
parser: {
|
|
@@ -298,7 +340,7 @@ const conductionVue3Fixes = [
|
|
|
298
340
|
name: 'conduction/language-level',
|
|
299
341
|
files: ALL_SCRIPT_FILES,
|
|
300
342
|
languageOptions: {
|
|
301
|
-
ecmaVersion:
|
|
343
|
+
ecmaVersion: ECMA_LANGUAGE_LEVEL,
|
|
302
344
|
sourceType: 'module',
|
|
303
345
|
// Repeated on `parserOptions` deliberately: `eslint-plugin-import`
|
|
304
346
|
// resolves the language level from `context.parserOptions`, which in
|
|
@@ -306,7 +348,7 @@ const conductionVue3Fixes = [
|
|
|
306
348
|
// `languageOptions.ecmaVersion`. Leaving it off is what let a stale
|
|
307
349
|
// `ecmaVersion: 6` make the import plugin choke on `?.` and `??`.
|
|
308
350
|
parserOptions: {
|
|
309
|
-
ecmaVersion:
|
|
351
|
+
ecmaVersion: ECMA_LANGUAGE_LEVEL,
|
|
310
352
|
sourceType: 'module',
|
|
311
353
|
},
|
|
312
354
|
},
|
|
@@ -316,7 +358,7 @@ const conductionVue3Fixes = [
|
|
|
316
358
|
files: ['**/*.vue'],
|
|
317
359
|
languageOptions: {
|
|
318
360
|
parser: vueParser,
|
|
319
|
-
ecmaVersion:
|
|
361
|
+
ecmaVersion: ECMA_LANGUAGE_LEVEL,
|
|
320
362
|
sourceType: 'module',
|
|
321
363
|
parserOptions: vueSfcParserOptions,
|
|
322
364
|
},
|
|
@@ -349,6 +391,7 @@ const conductionVue3 = [
|
|
|
349
391
|
|
|
350
392
|
module.exports = {
|
|
351
393
|
ECMA_VERSION,
|
|
394
|
+
ECMA_LANGUAGE_LEVEL,
|
|
352
395
|
vueSfcParserOptions,
|
|
353
396
|
vueDeprecationRules,
|
|
354
397
|
vueEventCasingRules,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/nextcloud-vue",
|
|
3
|
-
"version": "2.1.0-vue3.
|
|
3
|
+
"version": "2.1.0-vue3.11",
|
|
4
4
|
"description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
|
|
5
5
|
"license": "EUPL-1.2",
|
|
6
6
|
"author": "Conduction B.V. <info@conduction.nl>",
|
package/testing/index.js
CHANGED
|
@@ -32,12 +32,16 @@ export { expectAccessible, WCAG_AA_TAGS } from '../src/testing/index.js'
|
|
|
32
32
|
export {
|
|
33
33
|
SUPPORT_DIALOG_STORAGE_PREFIX,
|
|
34
34
|
WALKTHROUGH_STORAGE_PREFIX,
|
|
35
|
+
CHROME_DIALOG_SELECTORS,
|
|
36
|
+
FIRST_RUN_WIZARD_ROUTE,
|
|
35
37
|
seedSupportDialogSeen,
|
|
36
38
|
seedWalkthroughSeen,
|
|
37
39
|
seedFirstVisitOverlaysSeen,
|
|
38
40
|
dismissWalkthrough,
|
|
39
41
|
dismissSupportDialog,
|
|
40
42
|
dismissFirstVisitOverlays,
|
|
43
|
+
appDialog,
|
|
44
|
+
retireFirstRunWizard,
|
|
41
45
|
mountedAppIds,
|
|
42
46
|
mountedComponents,
|
|
43
47
|
mountedComponentNames,
|
package/testing/playwright.d.ts
CHANGED
|
@@ -30,6 +30,40 @@ export interface CnTestPage {
|
|
|
30
30
|
keyboard: { press(key: string): Promise<void> }
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Minimal structural stand-in for Playwright's `BrowserContext`.
|
|
35
|
+
*
|
|
36
|
+
* Accepted by the seeding helpers so a seed can cover every page the context
|
|
37
|
+
* opens AND survive into `storageState()` — which the page-scoped match-all
|
|
38
|
+
* form cannot do. See {@link seedFirstVisitOverlaysSeen}.
|
|
39
|
+
*/
|
|
40
|
+
export interface CnTestBrowserContext {
|
|
41
|
+
addInitScript(script: ((...args: any[]) => void) | string, arg?: any): Promise<void>
|
|
42
|
+
pages(): CnTestPage[]
|
|
43
|
+
newPage(): Promise<CnTestPage>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Anything the seeding helpers can be pointed at. */
|
|
47
|
+
export type CnSeedTarget = CnTestPage | CnTestBrowserContext
|
|
48
|
+
|
|
49
|
+
/** Options for {@link appDialog}. */
|
|
50
|
+
export interface CnAppDialogOptions {
|
|
51
|
+
/** Extra selectors treated as chrome, added to `CHROME_DIALOG_SELECTORS`. */
|
|
52
|
+
exclude?: string[]
|
|
53
|
+
/** Return the full match set instead of `.first()`. */
|
|
54
|
+
all?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Outcome of {@link retireFirstRunWizard}. */
|
|
58
|
+
export interface CnWizardRetirement {
|
|
59
|
+
/** HTTP status of the DELETE, or `-1` when the request itself threw. */
|
|
60
|
+
status: number
|
|
61
|
+
/** False when the firstrunwizard app is not installed (404). */
|
|
62
|
+
installed: boolean
|
|
63
|
+
/** True when no wizard will block clicks — a 2xx, or a 404. */
|
|
64
|
+
cleared: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
/** One mounted component instance, reduced to a JSON-safe shape. */
|
|
34
68
|
export interface CnMountedComponent {
|
|
35
69
|
/** The component's `name` option, or the `<script setup>` `__name` fallback. */
|
|
@@ -48,20 +82,51 @@ export interface CnDismissOptions {
|
|
|
48
82
|
maxDialogs?: number
|
|
49
83
|
}
|
|
50
84
|
|
|
51
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* localStorage key prefix `useSupportDialog` writes its "seen" flag under.
|
|
87
|
+
* Exported so a spec can assert against a saved `storageState` directly.
|
|
88
|
+
*/
|
|
52
89
|
export const SUPPORT_DIALOG_STORAGE_PREFIX: string
|
|
53
90
|
|
|
54
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* localStorage key prefix `useWalkthrough` mirrors the last-seen version under.
|
|
93
|
+
* Exported so a spec can assert against a saved `storageState` directly.
|
|
94
|
+
*/
|
|
55
95
|
export const WALKTHROUGH_STORAGE_PREFIX: string
|
|
56
96
|
|
|
57
|
-
|
|
58
|
-
export
|
|
59
|
-
|
|
97
|
+
/** `[role="dialog"]` selectors that are NC / nc-vue chrome, not the app's modal. */
|
|
98
|
+
export const CHROME_DIALOG_SELECTORS: string[]
|
|
99
|
+
|
|
100
|
+
/** Nextcloud's own first-run wizard dismissal route. */
|
|
101
|
+
export const FIRST_RUN_WIZARD_ROUTE: string
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Seed the support dialog as already seen.
|
|
105
|
+
*
|
|
106
|
+
* Passing a `CnTestBrowserContext` requires EXPLICIT app ids: `'*'` installs a
|
|
107
|
+
* `getItem` shim, which cannot serialise into `storageState()`, and is rejected
|
|
108
|
+
* rather than silently persisting nothing.
|
|
109
|
+
*
|
|
110
|
+
* @throws When `target` is a BrowserContext and `appId` is `'*'` or omitted.
|
|
111
|
+
*/
|
|
112
|
+
export function seedSupportDialogSeen(target: CnSeedTarget, appId?: string | string[]): Promise<void>
|
|
113
|
+
|
|
114
|
+
/** As {@link seedSupportDialogSeen}, for the `CnWalkthrough` tour. */
|
|
115
|
+
export function seedWalkthroughSeen(target: CnSeedTarget, appId?: string | string[], version?: string): Promise<void>
|
|
116
|
+
|
|
117
|
+
/** Both first-visit overlays at once. Prefer the BrowserContext form in a global-setup. */
|
|
118
|
+
export function seedFirstVisitOverlaysSeen(target: CnSeedTarget, appId?: string | string[]): Promise<void>
|
|
60
119
|
|
|
61
120
|
export function dismissWalkthrough(page: CnTestPage, options?: CnDismissOptions): Promise<boolean>
|
|
62
121
|
export function dismissSupportDialog(page: CnTestPage, options?: CnDismissOptions): Promise<number>
|
|
63
122
|
export function dismissFirstVisitOverlays(page: CnTestPage, options?: CnDismissOptions): Promise<void>
|
|
64
123
|
|
|
124
|
+
/** A `Locator` for the app's own modal, excluding NC and nc-vue chrome dialogs. */
|
|
125
|
+
export function appDialog(page: CnTestPage, options?: CnAppDialogOptions): CnTestLocator
|
|
126
|
+
|
|
127
|
+
/** Retire Nextcloud's `#firstrunwizard` server-side. A 404 is reported, not thrown. */
|
|
128
|
+
export function retireFirstRunWizard(page: CnTestPage, options?: { route?: string }): Promise<CnWizardRetirement>
|
|
129
|
+
|
|
65
130
|
export function mountedAppIds(page: CnTestPage): Promise<string[]>
|
|
66
131
|
export function mountedComponents(page: CnTestPage): Promise<CnMountedComponent[]>
|
|
67
132
|
export function mountedComponentNames(page: CnTestPage): Promise<string[]>
|
package/testing/playwright.js
CHANGED
|
@@ -21,6 +21,16 @@
|
|
|
21
21
|
* `__vueParentComponent` are never stamped onto elements. Consumers are
|
|
22
22
|
* forced to walk from `container.__vue_app__` instead. nc-vue creates that
|
|
23
23
|
* constraint, so nc-vue should ship the workaround.
|
|
24
|
+
* - `appDialog()` exists because `CnSupportDialog` is itself a
|
|
25
|
+
* `[role="dialog"]`, so the obvious `getByRole('dialog').first()` can match
|
|
26
|
+
* nc-vue's overlay after a click that never landed and report a modal the
|
|
27
|
+
* spec never opened as showing — a PASSING test for a broken flow.
|
|
28
|
+
*
|
|
29
|
+
* The one exception is `retireFirstRunWizard()`, which is Nextcloud's overlay
|
|
30
|
+
* rather than nc-vue's. It ships here because it has the identical failure
|
|
31
|
+
* shape, every consuming app is a Nextcloud app, and nothing else in the fleet
|
|
32
|
+
* owns a shared e2e layer — leaving it out meant 13 apps re-solving it, which
|
|
33
|
+
* is exactly the duplication this module was created to end.
|
|
24
34
|
*
|
|
25
35
|
* The duplication this replaces was real and measurable: openconnector had
|
|
26
36
|
* reimplemented overlay dismissal THREE separate times inside one repository
|
|
@@ -142,6 +152,48 @@ const SUPPORT_DIALOG = '[data-testid-modal="cn-support-dialog"]'
|
|
|
142
152
|
*/
|
|
143
153
|
const APP_ROOT_ID_ATTR = 'data-nldesign-theme-scope'
|
|
144
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Nextcloud's own first-run wizard dismissal route.
|
|
157
|
+
*
|
|
158
|
+
* @type {string}
|
|
159
|
+
*/
|
|
160
|
+
const FIRST_RUN_WIZARD_ROUTE = '/index.php/apps/firstrunwizard/wizard'
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Selectors for `[role="dialog"]` elements that are NOT the application's own
|
|
164
|
+
* modal — see {@link appDialog}.
|
|
165
|
+
*
|
|
166
|
+
* WHAT IS DELIBERATELY ABSENT: `.modal-mask`.
|
|
167
|
+
*
|
|
168
|
+
* A consumer asked for it, and shipping it would have broken every app in the
|
|
169
|
+
* fleet. `@nextcloud/vue` v9's `NcModal` renders its ROOT element as
|
|
170
|
+
* `<div class="modal-mask" role="dialog" aria-modal="true">` (verified in
|
|
171
|
+
* `@nextcloud/vue/dist/chunks/NcModal-*.mjs`), and `NcDialog` is built on
|
|
172
|
+
* `NcModal`. So `.modal-mask` is not a chrome wrapper sitting on top of app
|
|
173
|
+
* dialogs — it IS the app's dialog, for every `NcModal`/`NcDialog` in every
|
|
174
|
+
* Conduction app. `[role="dialog"]:not(.modal-mask)` would therefore match
|
|
175
|
+
* NOTHING in a typical app, which is the same green-but-dead shape this helper
|
|
176
|
+
* exists to prevent: the locator resolves to zero elements, and an
|
|
177
|
+
* `expect(...).toBeHidden()` style assertion passes against absence.
|
|
178
|
+
*
|
|
179
|
+
* The chrome that motivated the request is already covered by name:
|
|
180
|
+
* `#firstrunwizard` (which carries `modal-mask--opaque`) and the nc-vue support
|
|
181
|
+
* dialog. An app that has a genuine reason to exclude `.modal-mask` — or any
|
|
182
|
+
* other overlay — can pass it via `options.exclude`.
|
|
183
|
+
*
|
|
184
|
+
* @type {string[]}
|
|
185
|
+
*/
|
|
186
|
+
const CHROME_DIALOG_SELECTORS = [
|
|
187
|
+
// Nextcloud's own welcome overlay.
|
|
188
|
+
'#firstrunwizard',
|
|
189
|
+
// nc-vue's support prompt, both the class and the stable test hook.
|
|
190
|
+
'.cn-support-dialog',
|
|
191
|
+
SUPPORT_DIALOG,
|
|
192
|
+
// Nextcloud's legacy jQuery dialog (`OC.dialogs.*`), still used by core for
|
|
193
|
+
// file pickers and confirmations.
|
|
194
|
+
'.oc-dialog',
|
|
195
|
+
]
|
|
196
|
+
|
|
145
197
|
/**
|
|
146
198
|
* Normalise the `appId` argument shared by the seeding helpers.
|
|
147
199
|
*
|
|
@@ -157,42 +209,61 @@ function normaliseAppIds(appId) {
|
|
|
157
209
|
}
|
|
158
210
|
|
|
159
211
|
/**
|
|
160
|
-
*
|
|
161
|
-
* back as already-seen, for both future navigations and the current document.
|
|
212
|
+
* Is this a Playwright `BrowserContext` rather than a `Page`?
|
|
162
213
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
214
|
+
* Both expose `addInitScript`, which is why the seeding helpers can accept
|
|
215
|
+
* either. `newPage` + `pages` is what only a context has; `goto` is what only a
|
|
216
|
+
* page has. Duck-typed, like everything else in this module, so the same check
|
|
217
|
+
* works for `playwright-core` and for a fixture stand-in.
|
|
167
218
|
*
|
|
168
|
-
* @param {object}
|
|
169
|
-
* @
|
|
170
|
-
* @param {string[]} ids Exact app ids to cover (also covers `id + '-*'`).
|
|
171
|
-
* @param {boolean} matchAll Cover every app id under the prefix.
|
|
172
|
-
* @param {string} value Value the shimmed key reads back as.
|
|
173
|
-
* @return {Promise<void>}
|
|
219
|
+
* @param {object} target Page or BrowserContext.
|
|
220
|
+
* @return {boolean} True for a BrowserContext.
|
|
174
221
|
*/
|
|
175
|
-
|
|
176
|
-
|
|
222
|
+
function isBrowserContext(target) {
|
|
223
|
+
return !!target
|
|
224
|
+
&& typeof target.newPage === 'function'
|
|
225
|
+
&& typeof target.pages === 'function'
|
|
226
|
+
&& typeof target.goto !== 'function'
|
|
227
|
+
}
|
|
177
228
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
229
|
+
/**
|
|
230
|
+
* The explanation attached to every refusal of `'*'` in a persisting scope.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} scope Human name of the call that was refused.
|
|
233
|
+
* @return {string} Error message.
|
|
234
|
+
*/
|
|
235
|
+
function matchAllRefusal(scope) {
|
|
236
|
+
return `${scope}: '*' (or an omitted appId) cannot be persisted.\n`
|
|
237
|
+
+ 'The match-all form works by installing a `Storage.prototype.getItem` shim, and a\n'
|
|
238
|
+
+ 'shim is a live function on one page — it writes no concrete keys, so it cannot\n'
|
|
239
|
+
+ 'serialise into `context.storageState()`. Measured: with \'*\' the in-page\n'
|
|
240
|
+
+ '`getItem` reads back "1" while the saved storageState contains NO key at all,\n'
|
|
241
|
+
+ 'so a global-setup looks correct and then silently fails for every spec.\n'
|
|
242
|
+
+ 'Pass the explicit app id(s) instead — e.g. seedFirstVisitOverlaysSeen(context,\n'
|
|
243
|
+
+ "'openconnector') — which takes the write-through branch and rides in\n"
|
|
244
|
+
+ 'storageState. `mountedAppIds(page)` will tell you the ids in play, including\n'
|
|
245
|
+
+ 'nested CnAppRoots.'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Runs inside the browser. Kept as a single self-contained function so it can
|
|
250
|
+
* be handed to `addInitScript` (future navigations) and to `evaluate` (a
|
|
251
|
+
* document that is already open) without the two drifting apart.
|
|
252
|
+
*
|
|
253
|
+
* Returns the number of CONCRETE keys it managed to write, which is what the
|
|
254
|
+
* context-scoped path uses to tell a real seed from a no-op.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} args The serialised `{ prefix, ids, matchAll, value }`.
|
|
257
|
+
* @return {number} Concrete keys written to the backing store.
|
|
258
|
+
*/
|
|
259
|
+
const applySeed = (args) => {
|
|
260
|
+
const store = globalThis.__cnSeenSeeds || (globalThis.__cnSeenSeeds = [])
|
|
261
|
+
store.push(args)
|
|
262
|
+
|
|
263
|
+
if (!globalThis.__cnSeenShimInstalled) {
|
|
264
|
+
globalThis.__cnSeenShimInstalled = true
|
|
265
|
+
const proto = globalThis.Storage && globalThis.Storage.prototype
|
|
266
|
+
if (proto) {
|
|
196
267
|
const original = proto.getItem
|
|
197
268
|
proto.getItem = function getItem(key) {
|
|
198
269
|
const seeds = globalThis.__cnSeenSeeds || []
|
|
@@ -210,25 +281,115 @@ async function installSeenShim(page, prefix, ids, matchAll, value) {
|
|
|
210
281
|
return original.call(this, key)
|
|
211
282
|
}
|
|
212
283
|
}
|
|
284
|
+
}
|
|
213
285
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
286
|
+
// Write the concrete keys THROUGH to the backing store. This is the only
|
|
287
|
+
// part that survives into `storageState`, and it is why an explicit app id
|
|
288
|
+
// is durable while `'*'` is not: there is no such thing as a concrete key
|
|
289
|
+
// for "every app".
|
|
290
|
+
let written = 0
|
|
291
|
+
if (!args.matchAll) {
|
|
292
|
+
for (const id of args.ids) {
|
|
293
|
+
try {
|
|
294
|
+
globalThis.localStorage.setItem(args.prefix + id, args.value)
|
|
295
|
+
written++
|
|
296
|
+
} catch (e) {
|
|
297
|
+
/* private mode / quota / opaque origin — the shim still covers reads */
|
|
224
298
|
}
|
|
225
299
|
}
|
|
226
300
|
}
|
|
301
|
+
return written
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Make `context.storageState()` throw for the rest of the run, because a
|
|
306
|
+
* match-all seed was installed on one of its pages and CANNOT be in there.
|
|
307
|
+
*
|
|
308
|
+
* This is the part that makes the footgun unreachable rather than merely
|
|
309
|
+
* documented. `seedSupportDialogSeen(page, '*')` reads back `"1"` from inside
|
|
310
|
+
* the page, so the setup step that saves the state has no way to notice it
|
|
311
|
+
* saved nothing — the failure only shows up later, in every spec, as an overlay
|
|
312
|
+
* that "should have been seeded". Poisoning `storageState` moves the failure to
|
|
313
|
+
* the exact line that is wrong.
|
|
314
|
+
*
|
|
315
|
+
* The original method is preserved as `__cnOriginalStorageState` for the rare
|
|
316
|
+
* caller that knows what it is doing.
|
|
317
|
+
*
|
|
318
|
+
* @param {object} page Playwright `Page` (duck-typed).
|
|
319
|
+
* @param {string} scope Human name of the seeding call, for the message.
|
|
320
|
+
* @return {void}
|
|
321
|
+
*/
|
|
322
|
+
function poisonStorageState(page, scope) {
|
|
323
|
+
if (!page || typeof page.context !== 'function') {
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
let context
|
|
327
|
+
try {
|
|
328
|
+
context = page.context()
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
if (!context || typeof context.storageState !== 'function' || context.__cnStorageStatePoisoned) {
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
const original = context.storageState.bind(context)
|
|
336
|
+
context.__cnStorageStatePoisoned = true
|
|
337
|
+
context.__cnOriginalStorageState = original
|
|
338
|
+
context.storageState = async () => {
|
|
339
|
+
throw new Error(matchAllRefusal(scope)
|
|
340
|
+
+ '\n\nThis context therefore refuses to save a storageState that would be a\n'
|
|
341
|
+
+ 'silent no-op. Re-seed with explicit ids, or call\n'
|
|
342
|
+
+ '`context.__cnOriginalStorageState()` if you really do want the state without\n'
|
|
343
|
+
+ 'the seed in it.')
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Install the seed on a `Page` or on a `BrowserContext`.
|
|
349
|
+
*
|
|
350
|
+
* PAGE scope covers the current document plus every later navigation of that
|
|
351
|
+
* one page. CONTEXT scope covers every page the context already has and every
|
|
352
|
+
* page it will open — and it is the scope to use when the state is going to be
|
|
353
|
+
* saved with `context.storageState()`, because it REFUSES the match-all form
|
|
354
|
+
* that cannot be saved.
|
|
355
|
+
*
|
|
356
|
+
* @param {object} target Playwright `Page` or `BrowserContext` (duck-typed).
|
|
357
|
+
* @param {string} prefix Storage key prefix.
|
|
358
|
+
* @param {string[]} ids Exact app ids to cover (also covers `id + '-*'`).
|
|
359
|
+
* @param {boolean} matchAll Cover every app id under the prefix.
|
|
360
|
+
* @param {string} value Value the seeded key reads back as.
|
|
361
|
+
* @param {string} scope Human name of the calling helper, for error messages.
|
|
362
|
+
* @return {Promise<void>}
|
|
363
|
+
* @throws {Error} When a BrowserContext is seeded with `'*'`.
|
|
364
|
+
*/
|
|
365
|
+
async function installSeenShim(target, prefix, ids, matchAll, value, scope) {
|
|
366
|
+
const payload = { prefix, ids, matchAll, value }
|
|
367
|
+
|
|
368
|
+
if (isBrowserContext(target)) {
|
|
369
|
+
if (matchAll) {
|
|
370
|
+
// Refused by construction: a context-scoped seed exists to be saved,
|
|
371
|
+
// and this form cannot be. Throwing here is the whole point — the
|
|
372
|
+
// alternative is the measured failure in the message.
|
|
373
|
+
throw new Error(matchAllRefusal(scope))
|
|
374
|
+
}
|
|
375
|
+
// Covers pages opened later, and later navigations of pages already open.
|
|
376
|
+
await target.addInitScript(applySeed, payload)
|
|
377
|
+
// …and the documents that are already loaded, so a context seeded after
|
|
378
|
+
// the first `goto()` still lands in `storageState()`.
|
|
379
|
+
for (const page of target.pages()) {
|
|
380
|
+
await page.evaluate(applySeed, payload).catch(() => 0)
|
|
381
|
+
}
|
|
382
|
+
return
|
|
383
|
+
}
|
|
227
384
|
|
|
228
|
-
await
|
|
385
|
+
await target.addInitScript(applySeed, payload)
|
|
229
386
|
// Best-effort for a page that is already open: `addInitScript` only affects
|
|
230
387
|
// subsequent navigations, and callers do sometimes seed mid-test.
|
|
231
|
-
await
|
|
388
|
+
await target.evaluate(applySeed, payload).catch(() => 0)
|
|
389
|
+
|
|
390
|
+
if (matchAll) {
|
|
391
|
+
poisonStorageState(target, scope)
|
|
392
|
+
}
|
|
232
393
|
}
|
|
233
394
|
|
|
234
395
|
/**
|
|
@@ -256,9 +417,31 @@ async function installSeenShim(page, prefix, ids, matchAll, value) {
|
|
|
256
417
|
* nested id does not follow that convention, pass `'*'` (or the explicit list)
|
|
257
418
|
* to cover every app on the page.
|
|
258
419
|
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
420
|
+
* `'*'` AND `storageState` DO NOT MIX — and the helper now enforces that.
|
|
421
|
+
* The match-all form has no concrete keys to write, so it works by shimming
|
|
422
|
+
* `Storage.prototype.getItem`. A shim is a live function on one page; it cannot
|
|
423
|
+
* serialise. Measured on openconnector:
|
|
424
|
+
*
|
|
425
|
+
* ```
|
|
426
|
+
* explicit 'openconnector' in-page getItem: "1" persisted: cn-support-dialog-shown:openconnector=1
|
|
427
|
+
* matchAll '*' in-page getItem: "1" persisted: NONE
|
|
428
|
+
* ```
|
|
429
|
+
*
|
|
430
|
+
* Both read back `"1"` inside the page, which is why a `global-setup` that
|
|
431
|
+
* saves `storageState` looked correct and then failed for every spec. So:
|
|
432
|
+
*
|
|
433
|
+
* - passing a `BrowserContext` with `'*'` THROWS immediately, and
|
|
434
|
+
* - passing a `Page` with `'*'` poisons that page's `context.storageState()`,
|
|
435
|
+
* so the save fails loudly instead of writing a state with nothing in it.
|
|
436
|
+
*
|
|
437
|
+
* Use the BrowserContext form with explicit ids whenever the state is going to
|
|
438
|
+
* be saved — see {@link seedFirstVisitOverlaysSeen}.
|
|
439
|
+
*
|
|
440
|
+
* @param {object} target Playwright `Page` or `BrowserContext`.
|
|
441
|
+
* @param {string|string[]} [appId] App id(s). `'*'`/omitted covers all, but is
|
|
442
|
+
* page-scoped only and can never be persisted.
|
|
261
443
|
* @return {Promise<void>}
|
|
444
|
+
* @throws {Error} When a `BrowserContext` is seeded with `'*'` or no appId.
|
|
262
445
|
*
|
|
263
446
|
* @example
|
|
264
447
|
* test.beforeEach(async ({ page }) => {
|
|
@@ -266,9 +449,9 @@ async function installSeenShim(page, prefix, ids, matchAll, value) {
|
|
|
266
449
|
* await page.goto('/apps/openbuild/')
|
|
267
450
|
* })
|
|
268
451
|
*/
|
|
269
|
-
async function seedSupportDialogSeen(
|
|
452
|
+
async function seedSupportDialogSeen(target, appId) {
|
|
270
453
|
const { ids, matchAll } = normaliseAppIds(appId)
|
|
271
|
-
await installSeenShim(
|
|
454
|
+
await installSeenShim(target, SUPPORT_DIALOG_STORAGE_PREFIX, ids, matchAll, '1', 'seedSupportDialogSeen')
|
|
272
455
|
}
|
|
273
456
|
|
|
274
457
|
/**
|
|
@@ -281,29 +464,48 @@ async function seedSupportDialogSeen(page, appId) {
|
|
|
281
464
|
* that answers `{"value": null}` (never written) falls back to this mirror,
|
|
282
465
|
* which is the common case.
|
|
283
466
|
*
|
|
284
|
-
* Same nested-`CnAppRoot` coverage rule
|
|
467
|
+
* Same nested-`CnAppRoot` coverage rule — and the same `'*'`/`storageState`
|
|
468
|
+
* refusal — as {@link seedSupportDialogSeen}.
|
|
285
469
|
*
|
|
286
|
-
* @param {object}
|
|
287
|
-
* @param {string|string[]} [appId] App id(s); `'*'
|
|
470
|
+
* @param {object} target Playwright `Page` or `BrowserContext`.
|
|
471
|
+
* @param {string|string[]} [appId] App id(s); `'*'`/omitted covers all, and is
|
|
472
|
+
* page-scoped only.
|
|
288
473
|
* @param {string} [version] Version to record as seen.
|
|
289
474
|
* @return {Promise<void>}
|
|
475
|
+
* @throws {Error} When a `BrowserContext` is seeded with `'*'` or no appId.
|
|
290
476
|
*/
|
|
291
|
-
async function seedWalkthroughSeen(
|
|
477
|
+
async function seedWalkthroughSeen(target, appId, version = FUTURE_VERSION) {
|
|
292
478
|
const { ids, matchAll } = normaliseAppIds(appId)
|
|
293
|
-
await installSeenShim(
|
|
479
|
+
await installSeenShim(target, WALKTHROUGH_STORAGE_PREFIX, ids, matchAll, String(version), 'seedWalkthroughSeen')
|
|
294
480
|
}
|
|
295
481
|
|
|
296
482
|
/**
|
|
297
483
|
* Seed both first-visit overlays in one call — the pre-emptive counterpart of
|
|
298
484
|
* {@link dismissFirstVisitOverlays}.
|
|
299
485
|
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
486
|
+
* PREFER THE `BrowserContext` FORM IN A `global-setup`. A context-scoped seed
|
|
487
|
+
* covers every page the context already has AND every page it opens later, and
|
|
488
|
+
* it refuses the `'*'` form outright — which is the form that reads back
|
|
489
|
+
* correctly inside the page and then persists nothing.
|
|
490
|
+
*
|
|
491
|
+
* @param {object} target Playwright `Page` or `BrowserContext`.
|
|
492
|
+
* @param {string|string[]} [appId] App id(s). Required (and explicit) when
|
|
493
|
+
* `target` is a `BrowserContext`.
|
|
302
494
|
* @return {Promise<void>}
|
|
495
|
+
* @throws {Error} When a `BrowserContext` is seeded with `'*'` or no appId.
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* // global-setup.js — durable for every spec, context and browser in the run.
|
|
499
|
+
* const context = await browser.newContext()
|
|
500
|
+
* await seedFirstVisitOverlaysSeen(context, 'openconnector')
|
|
501
|
+
* const page = await context.newPage()
|
|
502
|
+
* await page.goto('/apps/openconnector/')
|
|
503
|
+
* await retireFirstRunWizard(page)
|
|
504
|
+
* await context.storageState({ path: STORAGE_STATE })
|
|
303
505
|
*/
|
|
304
|
-
async function seedFirstVisitOverlaysSeen(
|
|
305
|
-
await seedSupportDialogSeen(
|
|
306
|
-
await seedWalkthroughSeen(
|
|
506
|
+
async function seedFirstVisitOverlaysSeen(target, appId) {
|
|
507
|
+
await seedSupportDialogSeen(target, appId)
|
|
508
|
+
await seedWalkthroughSeen(target, appId)
|
|
307
509
|
}
|
|
308
510
|
|
|
309
511
|
/**
|
|
@@ -417,6 +619,116 @@ async function dismissFirstVisitOverlays(page, options = {}) {
|
|
|
417
619
|
await dismissSupportDialog(page, options)
|
|
418
620
|
}
|
|
419
621
|
|
|
622
|
+
/**
|
|
623
|
+
* A `Locator` for the application's OWN modal — never the chrome on top of it.
|
|
624
|
+
*
|
|
625
|
+
* `page.getByRole('dialog').first()` is the obvious way to grab a modal and the
|
|
626
|
+
* wrong one. On a Nextcloud page at least two other things claim
|
|
627
|
+
* `role="dialog"`: `#firstrunwizard`, Nextcloud's welcome overlay, and
|
|
628
|
+
* `CnSupportDialog`, nc-vue's support prompt. Both are full-viewport masks that
|
|
629
|
+
* HIDE nothing a visibility assertion inspects, so they break clicks rather
|
|
630
|
+
* than renders — the button under them stays `toBeVisible()` while the click is
|
|
631
|
+
* swallowed with "subtree intercepts pointer events".
|
|
632
|
+
*
|
|
633
|
+
* The trap is what happens next. Because the overlays are themselves dialogs, a
|
|
634
|
+
* spec that clicks, misses, and then asserts on `getByRole('dialog').first()`
|
|
635
|
+
* matches the OVERLAY, goes green, and reports that a modal it never opened is
|
|
636
|
+
* showing. That is a passing test for a broken flow, and it is why this belongs
|
|
637
|
+
* in the shared layer rather than in one app's `support/` folder: nc-vue is what
|
|
638
|
+
* puts `CnSupportDialog` on the page, so nc-vue owns the workaround.
|
|
639
|
+
*
|
|
640
|
+
* Returns a `Locator`, so it composes:
|
|
641
|
+
* `appDialog(page).getByRole('button', { name: 'Save' })`.
|
|
642
|
+
*
|
|
643
|
+
* ON `.modal-mask`: it is NOT excluded by default, on purpose. It is
|
|
644
|
+
* `@nextcloud/vue`'s `NcModal` ROOT — the element that carries `role="dialog"`
|
|
645
|
+
* for every `NcModal` and `NcDialog`, including the app's own. Excluding it
|
|
646
|
+
* would make this locator match nothing at all. Pass it in `options.exclude` if
|
|
647
|
+
* a specific app really needs it.
|
|
648
|
+
*
|
|
649
|
+
* @param {object} page Playwright `Page`.
|
|
650
|
+
* @param {object} [options] `{ exclude, all }`.
|
|
651
|
+
* @param {string[]} [options.exclude] Extra selectors to treat as chrome, added
|
|
652
|
+
* to {@link CHROME_DIALOG_SELECTORS}.
|
|
653
|
+
* @param {boolean} [options.all] Return the full match set instead of `.first()`.
|
|
654
|
+
* @return {object} Playwright `Locator`.
|
|
655
|
+
*
|
|
656
|
+
* @example
|
|
657
|
+
* await page.getByRole('button', { name: 'Add source' }).click()
|
|
658
|
+
* await expect(appDialog(page)).toBeVisible()
|
|
659
|
+
* await appDialog(page).getByRole('textbox', { name: 'Name' }).fill('demo')
|
|
660
|
+
*/
|
|
661
|
+
function appDialog(page, options = {}) {
|
|
662
|
+
const exclude = CHROME_DIALOG_SELECTORS.concat(options.exclude || [])
|
|
663
|
+
// Self-exclusion, not descendant-exclusion: `filter({ hasNot })` asks about
|
|
664
|
+
// a dialog's CHILDREN, whereas the overlays being ruled out ARE the matched
|
|
665
|
+
// element. A `:not()` chain is the honest way to say it.
|
|
666
|
+
const notChrome = exclude.map((selector) => `:not(${selector})`).join('')
|
|
667
|
+
const located = page.locator(`[role="dialog"]${notChrome}`)
|
|
668
|
+
return options.all ? located : located.first()
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Retire Nextcloud's own first-run wizard for the logged-in user, SERVER-SIDE.
|
|
673
|
+
*
|
|
674
|
+
* The wizard mounts as `#firstrunwizard`, a `[role="dialog"]` carrying
|
|
675
|
+
* `modal-mask--opaque`, and it is the other full-viewport overlay a fresh
|
|
676
|
+
* instance puts in front of an app — the `CnSupportDialog` seed says nothing
|
|
677
|
+
* about it. Its failure mode is the nasty one described on {@link appDialog}:
|
|
678
|
+
* it hides nothing, so `toBeVisible()` keeps passing and only the CLICK is
|
|
679
|
+
* intercepted.
|
|
680
|
+
*
|
|
681
|
+
* `DELETE /apps/firstrunwizard/wizard` is the wizard app's own dismissal route
|
|
682
|
+
* and records the result against the user, so unlike a `localStorage` seed it
|
|
683
|
+
* holds for every spec, every context and every browser in the run — one call
|
|
684
|
+
* in `global-setup` instead of a re-dismissal in every `beforeEach`. Issued
|
|
685
|
+
* from inside the page so the session cookie and the CSRF token come along for
|
|
686
|
+
* free.
|
|
687
|
+
*
|
|
688
|
+
* GRACEFUL WHEN THE APP IS NOT INSTALLED: a `404` means there is no wizard to
|
|
689
|
+
* retire, which is a success for the caller's purposes, not a failure. It is
|
|
690
|
+
* reported as `{ cleared: true, installed: false }` rather than thrown, so a
|
|
691
|
+
* shared `global-setup` works on instances with and without the app.
|
|
692
|
+
*
|
|
693
|
+
* @param {object} page Playwright `Page`, already logged in and on a Nextcloud
|
|
694
|
+
* page (the request is same-origin and needs `window.OC.requestToken`).
|
|
695
|
+
* @param {object} [options] `{ route }` to override the dismissal route.
|
|
696
|
+
* @return {Promise<{status: number, cleared: boolean, installed: boolean}>}
|
|
697
|
+
* `status` is the HTTP status, or `-1` when the request itself threw.
|
|
698
|
+
* `cleared` is true when nothing will block clicks (2xx, or 404 = not
|
|
699
|
+
* installed). `installed` distinguishes the two.
|
|
700
|
+
*
|
|
701
|
+
* @example
|
|
702
|
+
* const { cleared, status } = await retireFirstRunWizard(page)
|
|
703
|
+
* if (!cleared) {
|
|
704
|
+
* console.warn(`first-run wizard dismissal returned ${status}`)
|
|
705
|
+
* }
|
|
706
|
+
*/
|
|
707
|
+
async function retireFirstRunWizard(page, options = {}) {
|
|
708
|
+
const route = options.route || FIRST_RUN_WIZARD_ROUTE
|
|
709
|
+
|
|
710
|
+
const status = await page.evaluate(async (url) => {
|
|
711
|
+
try {
|
|
712
|
+
const oc = globalThis.OC || {}
|
|
713
|
+
const res = await globalThis.fetch(url, {
|
|
714
|
+
method: 'DELETE',
|
|
715
|
+
// Nextcloud rejects a state-changing request without this header.
|
|
716
|
+
headers: { requesttoken: oc.requestToken || '' },
|
|
717
|
+
})
|
|
718
|
+
return res.status
|
|
719
|
+
} catch (e) {
|
|
720
|
+
return -1
|
|
721
|
+
}
|
|
722
|
+
}, route).catch(() => -1)
|
|
723
|
+
|
|
724
|
+
const installed = status !== 404
|
|
725
|
+
return {
|
|
726
|
+
status,
|
|
727
|
+
installed,
|
|
728
|
+
cleared: (status >= 200 && status < 300) || status === 404,
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
420
732
|
/**
|
|
421
733
|
* The `appId` of every `CnAppRoot` currently mounted on the page, outer shell
|
|
422
734
|
* first.
|
|
@@ -640,14 +952,21 @@ async function readComponentProp(page, componentName, propName) {
|
|
|
640
952
|
}
|
|
641
953
|
|
|
642
954
|
module.exports = {
|
|
955
|
+
// Exported because consumers assert against a saved `storageState` file
|
|
956
|
+
// directly — `state.origins[0].localStorage` keyed by these prefixes is the
|
|
957
|
+
// only way to prove a seed actually persisted rather than merely read back.
|
|
643
958
|
SUPPORT_DIALOG_STORAGE_PREFIX,
|
|
644
959
|
WALKTHROUGH_STORAGE_PREFIX,
|
|
960
|
+
CHROME_DIALOG_SELECTORS,
|
|
961
|
+
FIRST_RUN_WIZARD_ROUTE,
|
|
645
962
|
seedSupportDialogSeen,
|
|
646
963
|
seedWalkthroughSeen,
|
|
647
964
|
seedFirstVisitOverlaysSeen,
|
|
648
965
|
dismissWalkthrough,
|
|
649
966
|
dismissSupportDialog,
|
|
650
967
|
dismissFirstVisitOverlays,
|
|
968
|
+
appDialog,
|
|
969
|
+
retireFirstRunWizard,
|
|
651
970
|
mountedAppIds,
|
|
652
971
|
mountedComponents,
|
|
653
972
|
mountedComponentNames,
|