@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 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 a modern `ecmaVersion` on both
213
- `languageOptions` and `languageOptions.parserOptions`, wires
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`, and a
234
- component-tree accessor that works against a production bundle with
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
- await seedFirstVisitOverlaysSeen(page, 'openbuild') // also covers openbuild-<slug>
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
- /** ECMAScript level every Conduction app is linted against. */
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. A modern `ecmaVersion` / `sourceType`, set on BOTH `languageOptions` and
39
- * `languageOptions.parserOptions`, so `eslint-plugin-import` (which reads
40
- * `context.parserOptions`) can parse optional chaining, nullish
41
- * coalescing and spread instead of inventing warnings about them.
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 level every Conduction app is written against.
107
+ * The ECMAScript syntax FLOOR every Conduction app may rely on.
106
108
  *
107
- * 2022 is the floor that makes `?.`, `??`, `??=`, class fields and top-level
108
- * `await`-adjacent syntax parseable. It is deliberately a named export: an app
109
- * that must reconfigure a parser of its own should reuse this constant instead
110
- * of re-guessing a number (guessing is how `ecmaVersion: 6` survived a Vue 3
111
- * migration and produced 20 phantom `import/*` warnings).
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: ECMA_VERSION,
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: ECMA_VERSION,
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: ECMA_VERSION,
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: ECMA_VERSION,
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.10",
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,
@@ -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
- /** localStorage key prefix `useSupportDialog` writes its "seen" flag under. */
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
- /** localStorage key prefix `useWalkthrough` mirrors the last-seen version under. */
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
- export function seedSupportDialogSeen(page: CnTestPage, appId?: string | string[]): Promise<void>
58
- export function seedWalkthroughSeen(page: CnTestPage, appId?: string | string[], version?: string): Promise<void>
59
- export function seedFirstVisitOverlaysSeen(page: CnTestPage, appId?: string | string[]): Promise<void>
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[]>
@@ -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
- * Install the storage shim that makes a set of `{prefix}{appId}` keys read
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
- * The shim intercepts `Storage.prototype.getItem` rather than only writing the
164
- * keys, because of the nested-`CnAppRoot` caveat documented on
165
- * {@link seedSupportDialogSeen}: the id of a nested root is composed at run
166
- * time and cannot be enumerated before the page loads.
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} page Playwright `Page` (duck-typed).
169
- * @param {string} prefix Storage key prefix.
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
- async function installSeenShim(page, prefix, ids, matchAll, value) {
176
- const payload = { prefix, ids, matchAll, value }
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
- * Runs inside the browser. Kept as a single self-contained function so it
180
- * can be handed to both `addInitScript` (future navigations) and
181
- * `evaluate` (the document already open).
182
- *
183
- * @param {object} args The serialised payload.
184
- * @return {void}
185
- */
186
- const apply = (args) => {
187
- const store = globalThis.__cnSeenSeeds || (globalThis.__cnSeenSeeds = [])
188
- store.push(args)
189
-
190
- if (!globalThis.__cnSeenShimInstalled) {
191
- globalThis.__cnSeenShimInstalled = true
192
- const proto = globalThis.Storage && globalThis.Storage.prototype
193
- if (!proto) {
194
- return
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
- // Also write the concrete keys through, so any consumer reading the
215
- // backing store directly (or after the shim is torn down by a hard
216
- // reload of a different origin) sees the same answer.
217
- if (!args.matchAll) {
218
- for (const id of args.ids) {
219
- try {
220
- globalThis.localStorage.setItem(args.prefix + id, args.value)
221
- } catch (e) {
222
- /* private mode / quota — the shim already covers reads */
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 page.addInitScript(apply, payload)
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 page.evaluate(apply, payload).catch(() => {})
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
- * @param {object} page Playwright `Page`.
260
- * @param {string|string[]} [appId] App id(s); `'*'` or omitted covers all.
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(page, appId) {
452
+ async function seedSupportDialogSeen(target, appId) {
270
453
  const { ids, matchAll } = normaliseAppIds(appId)
271
- await installSeenShim(page, SUPPORT_DIALOG_STORAGE_PREFIX, ids, matchAll, '1')
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 as {@link seedSupportDialogSeen}.
467
+ * Same nested-`CnAppRoot` coverage rule — and the same `'*'`/`storageState`
468
+ * refusal — as {@link seedSupportDialogSeen}.
285
469
  *
286
- * @param {object} page Playwright `Page`.
287
- * @param {string|string[]} [appId] App id(s); `'*'` or omitted covers all.
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(page, appId, version = FUTURE_VERSION) {
477
+ async function seedWalkthroughSeen(target, appId, version = FUTURE_VERSION) {
292
478
  const { ids, matchAll } = normaliseAppIds(appId)
293
- await installSeenShim(page, WALKTHROUGH_STORAGE_PREFIX, ids, matchAll, String(version))
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
- * @param {object} page Playwright `Page`.
301
- * @param {string|string[]} [appId] App id(s); `'*'` or omitted covers all.
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(page, appId) {
305
- await seedSupportDialogSeen(page, appId)
306
- await seedWalkthroughSeen(page, appId)
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,