@conduction/nextcloud-vue 2.1.0-vue3.12 → 2.1.0-vue3.13

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/eslint/index.js CHANGED
@@ -52,6 +52,12 @@
52
52
  * hand. The Vue-3 half of the key pair
53
53
  * (`vue/no-v-for-template-key-on-child`) is untouched and stays armed —
54
54
  * see {@link vueInvertedVue2Rules}.
55
+ * 6. It changes HOW you lint, never WHICH FILES you lint. Exactly one layer
56
+ * carries a `files` glob — `**\/*.vue`, the only extension this preset
57
+ * supplies a parser for. A `files` glob in flat config also ENROLS the
58
+ * matched paths into the consumer's lint set, and enrolling a file type
59
+ * you cannot parse is a fatal error that silences every rule on it —
60
+ * see {@link VUE_SFC_FILES}.
55
61
  *
56
62
  * USAGE
57
63
  * -----
@@ -357,11 +363,65 @@ const vueEventCasingRules = {
357
363
  }
358
364
 
359
365
  /**
360
- * File globs the preset's language options apply to.
366
+ * The ONE file glob this preset is allowed to enrol, and the rule that says why.
367
+ *
368
+ * READ THIS BEFORE ADDING A `files` KEY TO ANY LAYER BELOW.
369
+ *
370
+ * In flat config a `files` glob does two different jobs at once, and only one
371
+ * of them is obvious:
372
+ *
373
+ * 1. it SCOPES the layer — "apply my options to these files"; and
374
+ * 2. it ENROLS those files — a path matched by some layer's `files` becomes a
375
+ * file ESLint lints, even though ESLint's own default set is only
376
+ * `**\/*.js`, `**\/*.mjs`, `**\/*.cjs`.
377
+ *
378
+ * A layer with NO `files` key does job 1 for every file the CONSUMER already
379
+ * lints and does not do job 2 at all. That is exactly what a shared
380
+ * language-level / rules layer wants, and getting it wrong shipped a
381
+ * regression:
382
+ *
383
+ * this preset used to scope its language-level and deprecation layers to
384
+ * `['**\/*.js', '**\/*.mjs', '**\/*.cjs', '**\/*.jsx', '**\/*.ts', '**\/*.mts',
385
+ * '**\/*.cts', '**\/*.tsx', '**\/*.vue']`. Adopting it therefore ENROLLED
386
+ * `.jsx`, `.ts`, `.tsx`, `.mts` and `.cts` into the lint set of every consumer
387
+ * — while supplying a parser for `.vue` only. Measured on portaliq's base
388
+ * config (`@nextcloud/eslint-config/vue3`, whose non-SFC parser is
389
+ * `@babel/eslint-parser` with no JSX plugin):
390
+ *
391
+ * ```
392
+ * base alone + Probe.jsx → NOT LINTED (0 findings — a vacuous zero)
393
+ * base alone + Probe.js → linted, 1 no-unused-vars (positive control)
394
+ * base + this preset + Probe.jsx → linted, FATAL "requires … parser plugin(s): jsx"
395
+ * standalone preset + Probe.ts → linted, FATAL
396
+ * standalone preset + Probe.tsx → linted, FATAL
397
+ * ```
398
+ *
399
+ * A `fatal` message stops ESLint evaluating EVERY OTHER RULE on that file, so
400
+ * an app with a React (or plain-TS) surface silently lost lint coverage of all
401
+ * of it — the identical failure shape to the `ecmaVersion: 2022` pin documented
402
+ * on {@link ECMA_LANGUAGE_LEVEL}, which fataled on the ES2024 `v` regexp flag
403
+ * and took the deprecation gate down with it.
404
+ *
405
+ * Note what the cause is NOT. Flat config DEEP-MERGES
406
+ * `languageOptions.parserOptions` across layers: spreading this preset last over
407
+ * a base that sets `{ requireConfigFile: false, ecmaFeatures: { jsx: true } }`
408
+ * yields `{ requireConfigFile: false, ecmaFeatures: { jsx: true },
409
+ * ecmaVersion: 'latest', sourceType: 'module' }` — nothing is dropped, and
410
+ * `tests/eslint/preset.spec.js` asserts it. "Merge instead of replace" would
411
+ * have been a no-op fix for a cause that was never there.
412
+ *
413
+ * So the rule: **enrol a file type only if this preset also supplies a parser
414
+ * that can read it.** It supplies one for `.vue` and nothing else, so `.vue` is
415
+ * the only glob here. Everything else is scoped by omission.
416
+ *
417
+ * A consumer that wants `.jsx` / `.ts` linted says so in ITS OWN config, where
418
+ * it can pair the extension with a parser that handles it; this preset's layers
419
+ * then apply to those files for free, because a layer with no `files` matches
420
+ * whatever the consumer lints.
361
421
  *
362
422
  * @type {string[]}
363
423
  */
364
- const ALL_SCRIPT_FILES = ['**/*.js', '**/*.mjs', '**/*.cjs', '**/*.jsx', '**/*.ts', '**/*.mts', '**/*.cts', '**/*.tsx', '**/*.vue']
424
+ const VUE_SFC_FILES = ['**/*.vue']
365
425
 
366
426
  /**
367
427
  * The composable FIX layer: language level, SFC parser wiring, the armed
@@ -378,7 +438,13 @@ const ALL_SCRIPT_FILES = ['**/*.js', '**/*.mjs', '**/*.cjs', '**/*.jsx', '**/*.t
378
438
  const conductionVue3Fixes = [
379
439
  {
380
440
  name: 'conduction/language-level',
381
- files: ALL_SCRIPT_FILES,
441
+ // NO `files` — deliberately. See {@link VUE_SFC_FILES}: a `files` glob
442
+ // also ENROLS those paths into the consumer's lint set, and this layer
443
+ // supplies no parser, so enrolling `.jsx`/`.ts`/`.tsx` handed them to
444
+ // whatever parser the base had and fataled the whole file. Without the
445
+ // key the layer applies to every file the consumer already lints —
446
+ // including `.jsx` once the consumer enrols it properly — and enrols
447
+ // nothing of its own.
382
448
  languageOptions: {
383
449
  ecmaVersion: ECMA_LANGUAGE_LEVEL,
384
450
  sourceType: 'module',
@@ -395,7 +461,9 @@ const conductionVue3Fixes = [
395
461
  },
396
462
  {
397
463
  name: 'conduction/vue-sfc-parser',
398
- files: ['**/*.vue'],
464
+ // The one enrolment this preset is entitled to make: it hands `.vue`
465
+ // files to `vue-eslint-parser`, which can actually read them.
466
+ files: VUE_SFC_FILES,
399
467
  languageOptions: {
400
468
  parser: vueParser,
401
469
  ecmaVersion: ECMA_LANGUAGE_LEVEL,
@@ -405,7 +473,18 @@ const conductionVue3Fixes = [
405
473
  },
406
474
  {
407
475
  name: 'conduction/vue3-deprecations',
408
- files: ['**/*.vue', '**/*.js', '**/*.mjs', '**/*.cjs', '**/*.ts', '**/*.tsx'],
476
+ // NO `files` — same reason as the language-level layer, and a rules-only
477
+ // layer enrols just as hard as one carrying `languageOptions`. Its old
478
+ // glob listed `**\/*.ts` and `**\/*.tsx`, which is how a plain-TypeScript
479
+ // file ended up being linted by a preset that ships no TypeScript parser.
480
+ //
481
+ // Applying to everything is also strictly BETTER coverage: eslint-plugin-vue
482
+ // treats `.jsx` and `.tsx` as Vue component files
483
+ // (`utils.isVueFile()` → `.vue || .jsx || .tsx`), so a Vue component
484
+ // authored as a render-function `.jsx` now gets the deprecation gate it
485
+ // never had under the old glob, on any consumer that lints `.jsx` at all.
486
+ // On files with no Vue component in them every one of these rules is a
487
+ // no-op, so there is no cost to the breadth.
409
488
  rules: {
410
489
  ...vueDeprecationRules,
411
490
  ...vueEventCasingRules,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.1.0-vue3.12",
3
+ "version": "2.1.0-vue3.13",
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
@@ -34,6 +34,9 @@ export {
34
34
  WALKTHROUGH_STORAGE_PREFIX,
35
35
  CHROME_DIALOG_SELECTORS,
36
36
  FIRST_RUN_WIZARD_ROUTE,
37
+ NO_USER_SESSION,
38
+ GUEST_SURFACE,
39
+ guestSurfaceStatus,
37
40
  seedSupportDialogSeen,
38
41
  seedWalkthroughSeen,
39
42
  seedFirstVisitOverlaysSeen,
@@ -58,10 +58,55 @@ export interface CnAppDialogOptions {
58
58
  export interface CnWizardRetirement {
59
59
  /** HTTP status of the DELETE, or `-1` when the request itself threw. */
60
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. */
61
+ /**
62
+ * False when the firstrunwizard app is not installed (404); `null` when the
63
+ * status could not tell us — a 401 is answered by Nextcloud's auth layer
64
+ * before the wizard app is ever consulted, so both booleans would be
65
+ * inventions.
66
+ */
67
+ installed: boolean | null
68
+ /**
69
+ * True when no wizard will block clicks — a 2xx, a 404, or a guest surface
70
+ * where the per-user wizard could never have rendered at all.
71
+ */
64
72
  cleared: boolean
73
+ /**
74
+ * True when the wizard could not exist on this surface, so nothing was
75
+ * dismissed and nothing needed to be. Assert `false` when a spec means to
76
+ * prove a REAL dismissal happened.
77
+ */
78
+ notApplicable: boolean
79
+ /** Why, when `notApplicable` — currently `'no-user-session'`. */
80
+ reason: string | null
81
+ }
82
+
83
+ /** Outcome of {@link dismissFirstVisitOverlays}. */
84
+ export interface CnOverlayDismissal {
85
+ /**
86
+ * True on a Nextcloud GUEST surface: nc-vue mounts `CnWalkthrough` and
87
+ * `CnSupportDialog` from `CnAppRoot`, and a logged-out page has no app root,
88
+ * so there is nothing to clear. The call short-circuits instead of spending
89
+ * two timeouts polling for elements that cannot appear.
90
+ */
91
+ notApplicable: boolean
92
+ /** Why, when `notApplicable` — currently `'guest-surface'`. */
93
+ reason: string | null
94
+ /** True when a walkthrough tour was actually closed. */
95
+ walkthroughDismissed: boolean
96
+ /** How many support dialogs were actually closed (nested roots raise more). */
97
+ supportDialogsDismissed: number
98
+ }
99
+
100
+ /** What kind of Nextcloud surface a page is — see {@link guestSurfaceStatus}. */
101
+ export interface CnSurfaceStatus {
102
+ /** A Nextcloud page, with no user session and no mounted `CnAppRoot`. */
103
+ guest: boolean
104
+ /** The logged-in user id, or `null`. */
105
+ user: string | null
106
+ /** Whether the page carries a Nextcloud request token at all. */
107
+ isNextcloudPage: boolean
108
+ /** `appId` of every mounted `CnAppRoot`, outer shell first. */
109
+ appRoots: string[]
65
110
  }
66
111
 
67
112
  /** One mounted component instance, reduced to a JSON-safe shape. */
@@ -100,6 +145,21 @@ export const CHROME_DIALOG_SELECTORS: string[]
100
145
  /** Nextcloud's own first-run wizard dismissal route. */
101
146
  export const FIRST_RUN_WIZARD_ROUTE: string
102
147
 
148
+ /** `reason` when {@link retireFirstRunWizard} finds no user session. */
149
+ export const NO_USER_SESSION: string
150
+
151
+ /** `reason` when {@link dismissFirstVisitOverlays} finds a guest surface. */
152
+ export const GUEST_SURFACE: string
153
+
154
+ /**
155
+ * Whether this page is a logged-in app surface or a Nextcloud GUEST surface.
156
+ *
157
+ * Call it AFTER the page has loaded. The seeding helpers cannot answer it for
158
+ * you — they run before `goto()`, where there is no document to interrogate —
159
+ * and on a guest surface they write keys that nothing ever reads.
160
+ */
161
+ export function guestSurfaceStatus(page: CnTestPage): Promise<CnSurfaceStatus>
162
+
103
163
  /**
104
164
  * Seed the support dialog as already seen.
105
165
  *
@@ -119,7 +179,7 @@ export function seedFirstVisitOverlaysSeen(target: CnSeedTarget, appId?: string
119
179
 
120
180
  export function dismissWalkthrough(page: CnTestPage, options?: CnDismissOptions): Promise<boolean>
121
181
  export function dismissSupportDialog(page: CnTestPage, options?: CnDismissOptions): Promise<number>
122
- export function dismissFirstVisitOverlays(page: CnTestPage, options?: CnDismissOptions): Promise<void>
182
+ export function dismissFirstVisitOverlays(page: CnTestPage, options?: CnDismissOptions): Promise<CnOverlayDismissal>
123
183
 
124
184
  /** A `Locator` for the app's own modal, excluding NC and nc-vue chrome dialogs. */
125
185
  export function appDialog(page: CnTestPage, options?: CnAppDialogOptions): CnTestLocator
@@ -159,6 +159,77 @@ const APP_ROOT_ID_ATTR = 'data-nldesign-theme-scope'
159
159
  */
160
160
  const FIRST_RUN_WIZARD_ROUTE = '/index.php/apps/firstrunwizard/wizard'
161
161
 
162
+ /**
163
+ * `reason` returned by {@link retireFirstRunWizard} when the page has no user
164
+ * session, so Nextcloud's wizard could not render there whatever the server
165
+ * said.
166
+ *
167
+ * @type {string}
168
+ */
169
+ const NO_USER_SESSION = 'no-user-session'
170
+
171
+ /**
172
+ * `reason` returned by {@link dismissFirstVisitOverlays} when the page is a
173
+ * Nextcloud GUEST surface: it is a Nextcloud page (it carries a request token)
174
+ * but has no user session and mounts no `CnAppRoot`, so none of the overlays
175
+ * these helpers clear can exist on it.
176
+ *
177
+ * @type {string}
178
+ */
179
+ const GUEST_SURFACE = 'guest-surface'
180
+
181
+ /**
182
+ * Read the page's Nextcloud session, IN THE BROWSER.
183
+ *
184
+ * A module-level arrow with no closure references, so Playwright can serialise
185
+ * it into `page.evaluate` — and so {@link retireFirstRunWizard} and
186
+ * {@link guestSurfaceStatus} cannot drift apart on what "logged in" means.
187
+ *
188
+ * Three sources, in descending order of authority:
189
+ *
190
+ * 1. `OC.getCurrentUser()` — the documented accessor, present on any page that
191
+ * loaded `core/js/dist/main`;
192
+ * 2. `OC.currentUser` — the older global some pages still expose;
193
+ * 3. the `data-user` attribute Nextcloud stamps on `<head>` — the only one of
194
+ * the three that survives on a stripped-down public template.
195
+ *
196
+ * `isNextcloudPage` is derived from the request TOKEN, not the user, and that
197
+ * separation is the point. Measured on a portaliq guest portal page: it emits
198
+ * `data-requesttoken` and NO `data-user`. Token-without-user is therefore
199
+ * positive evidence of a guest surface, whereas "neither present" just means
200
+ * this is not a Nextcloud page at all (an `about:blank`, a fixture, a page
201
+ * seeded before `goto()`) and nothing may be concluded from it.
202
+ *
203
+ * @return {{user: (string|null), isNextcloudPage: boolean}} Session facts.
204
+ */
205
+ const readSurfaceSession = () => {
206
+ const oc = globalThis.OC || {}
207
+ const head = (globalThis.document && globalThis.document.head) || null
208
+ const attr = (name) => (head && typeof head.getAttribute === 'function' ? head.getAttribute(name) : null)
209
+
210
+ let user = null
211
+ try {
212
+ if (typeof oc.getCurrentUser === 'function') {
213
+ const current = oc.getCurrentUser()
214
+ user = (current && current.uid) || null
215
+ }
216
+ } catch (e) {
217
+ /* a partially-initialised OC can throw; absence is the answer we want */
218
+ }
219
+ if (!user && typeof oc.currentUser === 'string' && oc.currentUser !== '') {
220
+ user = oc.currentUser
221
+ }
222
+ if (!user) {
223
+ user = attr('data-user') || null
224
+ }
225
+
226
+ const token = (typeof oc.requestToken === 'string' && oc.requestToken !== '')
227
+ ? oc.requestToken
228
+ : attr('data-requesttoken')
229
+
230
+ return { user: user || null, isNextcloudPage: Boolean(token) }
231
+ }
232
+
162
233
  /**
163
234
  * Selectors for `[role="dialog"]` elements that are NOT the application's own
164
235
  * modal — see {@link appDialog}.
@@ -437,6 +508,20 @@ async function installSeenShim(target, prefix, ids, matchAll, value, scope) {
437
508
  * Use the BrowserContext form with explicit ids whenever the state is going to
438
509
  * be saved — see {@link seedFirstVisitOverlaysSeen}.
439
510
  *
511
+ * INERT ON A GUEST SURFACE — READ THIS BEFORE DEBUGGING A SEED THAT "DID
512
+ * NOTHING". `CnSupportDialog` is auto-mounted by `CnAppRoot`, and a page with
513
+ * no user session does not mount one. Measured on a portaliq public portal
514
+ * page: `data-requesttoken` present, `data-user` absent, no `CnAppRoot` in the
515
+ * DOM. The seed still writes its key and still reads back `"1"` — it simply has
516
+ * no reader, because `useSupportDialog` never runs there.
517
+ *
518
+ * That is the RIGHT behaviour (there is no dialog to suppress) and the seed is
519
+ * deliberately left unconditional: it is normally called BEFORE `page.goto()`,
520
+ * where there is no document yet to interrogate, so a "is this a guest surface"
521
+ * probe here would be measuring `about:blank` and would answer wrong every
522
+ * time. Call {@link guestSurfaceStatus} AFTER the page has loaded when you need
523
+ * the answer, and {@link dismissFirstVisitOverlays} reports it for you.
524
+ *
440
525
  * @param {object} target Playwright `Page` or `BrowserContext`.
441
526
  * @param {string|string[]} [appId] App id(s). `'*'`/omitted covers all, but is
442
527
  * page-scoped only and can never be persisted.
@@ -464,8 +549,12 @@ async function seedSupportDialogSeen(target, appId) {
464
549
  * that answers `{"value": null}` (never written) falls back to this mirror,
465
550
  * which is the common case.
466
551
  *
467
- * Same nested-`CnAppRoot` coverage rule — and the same `'*'`/`storageState`
468
- * refusal — as {@link seedSupportDialogSeen}.
552
+ * Same nested-`CnAppRoot` coverage rule, the same `'*'`/`storageState` refusal,
553
+ * and the same GUEST-SURFACE caveat — the tour is mounted by `CnAppRoot`, so on
554
+ * a page with no user session and no app root this seed writes a key nothing
555
+ * ever reads. See {@link seedSupportDialogSeen} for all three, and
556
+ * {@link guestSurfaceStatus} for the probe that tells you which surface you are
557
+ * on.
469
558
  *
470
559
  * @param {object} target Playwright `Page` or `BrowserContext`.
471
560
  * @param {string|string[]} [appId] App id(s); `'*'`/omitted covers all, and is
@@ -488,6 +577,12 @@ async function seedWalkthroughSeen(target, appId, version = FUTURE_VERSION) {
488
577
  * it refuses the `'*'` form outright — which is the form that reads back
489
578
  * correctly inside the page and then persists nothing.
490
579
  *
580
+ * Both seeds are INERT on a guest surface, for the reason spelled out on
581
+ * {@link seedSupportDialogSeen}: nc-vue mounts neither overlay without a
582
+ * `CnAppRoot`, and a logged-out page has none. Harmless, and worth knowing
583
+ * before you spend an afternoon on a seed that "did not take" —
584
+ * {@link guestSurfaceStatus} answers it in one call.
585
+ *
491
586
  * @param {object} target Playwright `Page` or `BrowserContext`.
492
587
  * @param {string|string[]} [appId] App id(s). Required (and explicit) when
493
588
  * `target` is a `BrowserContext`.
@@ -610,13 +705,56 @@ async function dismissSupportDialog(page, options = {}) {
610
705
  * Order matters: the walkthrough's dimmer sits above the support dialog, so
611
706
  * the tour goes first or the dialog's close button is unreachable.
612
707
  *
708
+ * REPORTS "NOT APPLICABLE" ON A GUEST SURFACE INSTEAD OF SILENTLY DOING
709
+ * NOTHING. `CnWalkthrough` and `CnSupportDialog` are both auto-mounted BY
710
+ * `CnAppRoot`, so on a page that mounts no app root neither overlay exists and
711
+ * this helper has nothing to clear. That is correct behaviour and the wrong
712
+ * SHAPE: measured on a portaliq public portal page (request token present,
713
+ * `data-user` absent, no `CnAppRoot`), the call spent its full timeout budget
714
+ * twice over polling for elements that could not appear, and then returned the
715
+ * same `undefined` a successful dismissal returns. The consuming spec had no
716
+ * way to tell "cleared two overlays" from "there was nothing here" — so the
717
+ * next consumer gets to guess, or works around it locally.
718
+ *
719
+ * Now it returns a result. On a guest surface it short-circuits
720
+ * ({@link guestSurfaceStatus} decides), returning
721
+ * `{ notApplicable: true, reason: 'guest-surface' }` immediately instead of
722
+ * burning two timeouts. Everywhere else it reports what it actually did.
723
+ *
724
+ * The return value is additive: callers that ignore it are unaffected.
725
+ *
613
726
  * @param {object} page Playwright `Page`.
614
727
  * @param {object} [options] Forwarded to both helpers.
615
- * @return {Promise<void>}
728
+ * @return {Promise<{notApplicable: boolean, reason: (string|null),
729
+ * walkthroughDismissed: boolean, supportDialogsDismissed: number}>} What was
730
+ * cleared, or why nothing could be.
731
+ *
732
+ * @example
733
+ * const cleared = await dismissFirstVisitOverlays(page)
734
+ * if (cleared.notApplicable) {
735
+ * // guest surface: nc-vue mounts no overlays here, nothing was skipped
736
+ * }
616
737
  */
617
738
  async function dismissFirstVisitOverlays(page, options = {}) {
618
- await dismissWalkthrough(page, options)
619
- await dismissSupportDialog(page, options)
739
+ const surface = await guestSurfaceStatus(page)
740
+ if (surface.guest) {
741
+ return {
742
+ notApplicable: true,
743
+ reason: GUEST_SURFACE,
744
+ walkthroughDismissed: false,
745
+ supportDialogsDismissed: 0,
746
+ }
747
+ }
748
+
749
+ const walkthroughDismissed = await dismissWalkthrough(page, options)
750
+ const supportDialogsDismissed = await dismissSupportDialog(page, options)
751
+
752
+ return {
753
+ notApplicable: false,
754
+ reason: null,
755
+ walkthroughDismissed,
756
+ supportDialogsDismissed,
757
+ }
620
758
  }
621
759
 
622
760
  /**
@@ -690,17 +828,59 @@ function appDialog(page, options = {}) {
690
828
  * reported as `{ cleared: true, installed: false }` rather than thrown, so a
691
829
  * shared `global-setup` works on instances with and without the app.
692
830
  *
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`).
831
+ * TRUTHFUL ON A GUEST SURFACE — and it used to lie here.
832
+ * Unauthenticated, `DELETE /apps/firstrunwizard/wizard` answers `401`. The
833
+ * helper used to fold that into its catch-all failure branch and report
834
+ * `{ installed: true, cleared: false }` — i.e. "a blocking overlay remains".
835
+ * That is not merely unhelpful, it is FALSE: Nextcloud's first-run wizard is a
836
+ * per-user overlay and cannot render for a visitor with no session, so there
837
+ * was never anything there to block a click. A public-portal spec that trusted
838
+ * the return value would fail its own setup over an overlay that does not
839
+ * exist.
840
+ *
841
+ * So the session is consulted, not just the status code:
842
+ *
843
+ * | condition | result |
844
+ * | ---------------------------------- | ----------------------------------------------------------------------- |
845
+ * | 2xx | `{installed: true, cleared: true, notApplicable: false}` |
846
+ * | 404 | `{installed: false, cleared: true, notApplicable: false}` |
847
+ * | 401, or a Nextcloud page with no user | `{installed: null, cleared: true, notApplicable: true, reason: 'no-user-session'}` |
848
+ * | anything else | `{installed: true, cleared: false, notApplicable: false}` |
849
+ *
850
+ * WHY `cleared: true` FOR A GUEST. `cleared` documents one thing: "nothing will
851
+ * block clicks". On a guest surface nothing will, so `true` is the honest
852
+ * answer — reporting `false` would make every caller that guards on it treat a
853
+ * perfectly usable page as broken. `notApplicable: true` is what keeps it
854
+ * distinguishable from a real dismissal, so a spec that wants to assert the
855
+ * wizard was genuinely retired asserts `notApplicable === false` and gets a
856
+ * failure if it silently ran as a guest.
857
+ *
858
+ * WHY `installed: null` RATHER THAN A BOOLEAN. A `401` is answered by
859
+ * Nextcloud's auth layer before the wizard app is consulted at all, so the
860
+ * response carries no information about whether the app is installed. `true`
861
+ * and `false` would both be inventions; `null` says "not known from here". It
862
+ * is falsy, so existing `if (installed)` checks behave as they did.
863
+ *
864
+ * The 2xx and 404 branches are evaluated BEFORE the session check on purpose:
865
+ * if the server actually accepted the dismissal, that is dispositive and there
866
+ * is nothing to second-guess.
867
+ *
868
+ * @param {object} page Playwright `Page`, on a Nextcloud page (the request is
869
+ * same-origin and needs `window.OC.requestToken`). A logged-out page is
870
+ * handled rather than misreported — see the table above.
695
871
  * @param {object} [options] `{ route }` to override the dismissal route.
696
- * @return {Promise<{status: number, cleared: boolean, installed: boolean}>}
872
+ * @return {Promise<{status: number, cleared: boolean, installed: (boolean|null),
873
+ * notApplicable: boolean, reason: (string|null)}>}
697
874
  * `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.
875
+ * `cleared` is true when nothing will block clicks. `installed` is `null`
876
+ * when the status could not tell us. `notApplicable` is true when the wizard
877
+ * could never have rendered on this surface, with `reason` naming why.
700
878
  *
701
879
  * @example
702
- * const { cleared, status } = await retireFirstRunWizard(page)
703
- * if (!cleared) {
880
+ * const { cleared, notApplicable, status } = await retireFirstRunWizard(page)
881
+ * if (notApplicable) {
882
+ * // guest surface — there is no wizard here, and that is fine
883
+ * } else if (!cleared) {
704
884
  * console.warn(`first-run wizard dismissal returned ${status}`)
705
885
  * }
706
886
  */
@@ -721,11 +901,77 @@ async function retireFirstRunWizard(page, options = {}) {
721
901
  }
722
902
  }, route).catch(() => -1)
723
903
 
724
- const installed = status !== 404
904
+ if (status >= 200 && status < 300) {
905
+ return { status, installed: true, cleared: true, notApplicable: false, reason: null }
906
+ }
907
+ if (status === 404) {
908
+ return { status, installed: false, cleared: true, notApplicable: false, reason: null }
909
+ }
910
+
911
+ const session = await page.evaluate(readSurfaceSession)
912
+ .catch(() => ({ user: null, isNextcloudPage: false }))
913
+
914
+ // A 401 is Nextcloud's auth layer saying "no session" outright. The second
915
+ // arm covers instances that answer differently (403, or a redirect that
916
+ // resolves to something else) but whose page still shows the guest
917
+ // signature: a request token, no user.
918
+ if (status === 401 || (session.isNextcloudPage && !session.user)) {
919
+ return {
920
+ status,
921
+ installed: null,
922
+ cleared: true,
923
+ notApplicable: true,
924
+ reason: NO_USER_SESSION,
925
+ }
926
+ }
927
+
928
+ return { status, installed: true, cleared: false, notApplicable: false, reason: null }
929
+ }
930
+
931
+ /**
932
+ * What kind of Nextcloud surface this page is — logged-in app, or guest.
933
+ *
934
+ * Exported because the seeding helpers CANNOT answer this for you. They are
935
+ * meant to be called BEFORE `page.goto()` (that is what makes `addInitScript`
936
+ * work), and before a navigation there is no document to interrogate: probing
937
+ * at seed time would be measuring `about:blank`. So the honest split is that
938
+ * the seeds stay unconditional and cheap, and this is the probe you call AFTER
939
+ * the page has loaded when you need to know whether any of it mattered.
940
+ *
941
+ * Measured on a portaliq public portal page: `data-requesttoken` present,
942
+ * `data-user` absent, no `CnAppRoot` mounted. That combination is the
943
+ * signature, and all three parts are required:
944
+ *
945
+ * - a request token proves this is a Nextcloud page, so "no user" means
946
+ * logged-out rather than "not a Nextcloud page at all";
947
+ * - no user means `CnSupportDialog`'s and `CnWalkthrough`'s per-user state
948
+ * cannot be read or written;
949
+ * - no mounted `CnAppRoot` means neither overlay was ever instantiated —
950
+ * nc-vue auto-mounts both FROM the app root, so no root is no overlays.
951
+ *
952
+ * A guest page that DOES mount a `CnAppRoot` is not reported as a guest
953
+ * surface, because the overlays it mounts are real and still need clearing.
954
+ *
955
+ * @param {object} page Playwright `Page`.
956
+ * @return {Promise<{guest: boolean, user: (string|null), isNextcloudPage: boolean,
957
+ * appRoots: string[]}>} The surface facts, and the verdict derived from them.
958
+ *
959
+ * @example
960
+ * const surface = await guestSurfaceStatus(page)
961
+ * if (surface.guest) {
962
+ * // seedSupportDialogSeen / dismissFirstVisitOverlays are no-ops here
963
+ * }
964
+ */
965
+ async function guestSurfaceStatus(page) {
966
+ const session = await page.evaluate(readSurfaceSession)
967
+ .catch(() => ({ user: null, isNextcloudPage: false }))
968
+ const appRoots = await mountedAppIds(page).catch(() => [])
969
+
725
970
  return {
726
- status,
727
- installed,
728
- cleared: (status >= 200 && status < 300) || status === 404,
971
+ guest: Boolean(session.isNextcloudPage) && !session.user && appRoots.length === 0,
972
+ user: session.user || null,
973
+ isNextcloudPage: Boolean(session.isNextcloudPage),
974
+ appRoots,
729
975
  }
730
976
  }
731
977
 
@@ -959,6 +1205,11 @@ module.exports = {
959
1205
  WALKTHROUGH_STORAGE_PREFIX,
960
1206
  CHROME_DIALOG_SELECTORS,
961
1207
  FIRST_RUN_WIZARD_ROUTE,
1208
+ // The two `reason` strings. Exported so a spec can compare against the
1209
+ // constant instead of retyping the literal and drifting.
1210
+ NO_USER_SESSION,
1211
+ GUEST_SURFACE,
1212
+ guestSurfaceStatus,
962
1213
  seedSupportDialogSeen,
963
1214
  seedWalkthroughSeen,
964
1215
  seedFirstVisitOverlaysSeen,