@cat-factory/app 0.201.1 → 0.204.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +130 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
  3. package/app/components/initiative/InitiativePlanReview.vue +11 -1
  4. package/app/components/panels/AgentStepDetail.vue +10 -0
  5. package/app/components/panels/ResultWindowShell.vue +86 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +54 -0
  8. package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
  9. package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
  10. package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
  11. package/app/components/tutorial/TutorialCatalogue.vue +150 -0
  12. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
  13. package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
  14. package/app/components/tutorial/TutorialOverlay.vue +296 -40
  15. package/app/components/tutorial/TutorialPrompt.vue +41 -22
  16. package/app/composables/useNavContributions.ts +4 -1
  17. package/app/composables/useTutorialLaunch.ts +50 -0
  18. package/app/composables/useTutorialTours.ts +37 -9
  19. package/app/docs/consumer-extensions.md +24 -11
  20. package/app/modular/agent-kinds.ts +6 -0
  21. package/app/modular/nav-contributions.spec.ts +7 -0
  22. package/app/modular/nav-contributions.ts +25 -13
  23. package/app/modular/slots.ts +5 -2
  24. package/app/modular/tutorial-tours.spec.ts +189 -53
  25. package/app/modular/tutorial-tours.ts +57 -8
  26. package/app/pages/index.vue +7 -2
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +38 -2
  29. package/app/stores/tutorial.spec.ts +167 -0
  30. package/app/stores/tutorial.ts +140 -4
  31. package/app/types/domain.ts +9 -0
  32. package/app/types/execution.ts +5 -0
  33. package/app/utils/binaryOutput.spec.ts +307 -0
  34. package/app/utils/binaryOutput.ts +343 -0
  35. package/app/utils/tutorial.spec.ts +179 -9
  36. package/app/utils/tutorial.ts +233 -22
  37. package/i18n/locales/de.json +89 -7
  38. package/i18n/locales/en.json +101 -7
  39. package/i18n/locales/es.json +89 -7
  40. package/i18n/locales/fr.json +89 -7
  41. package/i18n/locales/he.json +89 -7
  42. package/i18n/locales/it.json +89 -7
  43. package/i18n/locales/ja.json +89 -7
  44. package/i18n/locales/pl.json +89 -7
  45. package/i18n/locales/tr.json +89 -7
  46. package/i18n/locales/uk.json +89 -7
  47. package/package.json +2 -2
@@ -1,18 +1,46 @@
1
1
  import { computed } from 'vue'
2
2
  import { useReactiveSlots } from '@modular-vue/runtime'
3
- import { sortTours } from '~/utils/tutorial'
4
- import type { TutorialTour } from '~/utils/tutorial'
3
+ import { createSharedComposables } from '@modular-vue/vue'
4
+ import { resolveTourCatalogue } from '~/utils/tutorial'
5
+ import type { TutorialCatalogueEntry, TutorialTour } from '~/utils/tutorial'
6
+ import type { AppDeps } from '~/modular/registry'
5
7
  import type { AppSlots } from '~/modular/nav-contributions'
6
8
 
7
9
  /**
8
- * The tours the current user may take: the merged `tutorialTours` slot (first-party +
9
- * consumer-contributed), already gated per tour by `navSlotFilter` (each tour's `when`
10
- * runs over the reactive gates service, so a permission flip shows/hides tours live),
11
- * in deterministic catalog order. The single source both the launch prompt and the
12
- * coach-mark overlay resolve tours from.
10
+ * The registered `gates` service the SAME reactive object `navSlotFilter` reads, resolved
11
+ * from the modular app's shared dependencies rather than rebuilt here, so the tutorial
12
+ * surfaces and the nav can never disagree about what this board offers. Reading its getters
13
+ * inside a `computed` tracks the underlying stores, so availability re-resolves the instant a
14
+ * permission flips or a task lands on the board.
15
+ *
16
+ * `useOptional` (not `useService`) covers the bare-install case the nav filter also allows: a
17
+ * host that registered no gates withholds nothing rather than throwing.
18
+ */
19
+ const { useOptional } = createSharedComposables<AppDeps>()
20
+
21
+ /**
22
+ * The tutorial catalog as this board sees it, resolved ONCE for every surface that reads it.
23
+ *
24
+ * Two views over one resolution, and the difference between them is the point:
25
+ *
26
+ * - `tours` — what can be started right now. The launch prompt offers these, and the overlay
27
+ * resolves a running tour from them, exactly as when this gating lived in `navSlotFilter`.
28
+ * - `catalogue` — EVERY tour this deployment ships, each carrying why it is or isn't
29
+ * available. The catalogue surface needs the unavailable ones: a list that quietly omits
30
+ * four of six tours is indistinguishable from a deployment that ships two, and the user it
31
+ * fails is the one who came looking for the walkthrough they were told about.
32
+ *
33
+ * Gating cannot live in the slot filter for that reason — a `SlotFilter` maps slots to slots,
34
+ * so it can only drop, never annotate. `resolveTourCatalogue` is pure and does both.
13
35
  */
14
36
  export function useTutorialTours() {
15
37
  const slots = useReactiveSlots<AppSlots>()
16
- const tours = computed<TutorialTour[]>(() => sortTours(slots.value.tutorialTours ?? []))
17
- return { tours }
38
+ const gates = useOptional('gates')
39
+ const catalogue = computed<TutorialCatalogueEntry[]>(() =>
40
+ resolveTourCatalogue(slots.value.tutorialTours ?? [], gates.value),
41
+ )
42
+ const tours = computed<TutorialTour[]>(() =>
43
+ catalogue.value.filter((entry) => entry.availability === 'ready').map((entry) => entry.tour),
44
+ )
45
+ return { tours, catalogue }
18
46
  }
@@ -236,17 +236,17 @@ re-deriving the "which run is this / how did the model do" facts. **Composables*
236
236
  **components** must be named through the `#components` virtual module (see the boxed note
237
237
  below). Compose these:
238
238
 
239
- | Building block | Reference it as | What it gives you |
240
- | ----------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
241
- | `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window — backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. |
242
- | `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar — never reinvent run metadata. |
243
- | `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
244
- | `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
245
- | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
246
- | `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
247
- | `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path — from a board card or the inspector — carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead. |
248
- | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
249
- | `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
239
+ | Building block | Reference it as | What it gives you |
240
+ | ----------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
241
+ | `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window — backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. It also renders the universal per-step trailing sections (agent effort, pre-PR validation, binary outputs) off the ACTIVE step, so your window inherits them and must not re-render them itself. |
242
+ | `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar — never reinvent run metadata. |
243
+ | `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
244
+ | `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
245
+ | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
246
+ | `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
247
+ | `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path — from a board card or the inspector — carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead. |
248
+ | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
249
+ | `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
250
250
 
251
251
  > **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
252
252
  > layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
@@ -270,6 +270,19 @@ below). Compose these:
270
270
  > Hardening these building blocks into an explicitly exported, location-independent public
271
271
  > surface is slice G of the initiative.
272
272
 
273
+ ### Don't render step state the shell already owns
274
+
275
+ Some state the engine records on a step is deliberately NOT a result view's job, because the
276
+ record's scope is wider than any one kind: the agent's effort self-assessment, the pre-PR
277
+ validation report, and — for a `binary-output` generator — the artifacts it declared it stored
278
+ (`step.binaryOutputs`). `ResultWindowShell` resolves the active step itself and renders each as a
279
+ collapsible trailing section, and the generic step-detail panel renders the same components for a
280
+ step whose kind declares no window at all.
281
+
282
+ So a generator kind should declare a result view for its OWN output (or none), and leave the
283
+ artifact list alone — you get it either way, on every entry point, with no id to register. A
284
+ window that renders it again just shows it twice.
285
+
273
286
  The example `AcmeSecurityReport.vue` window is a full demonstration: it imports
274
287
  `ResultWindowShell` + `StepRunMeta` + `MarkdownProse` from `#components`, composes them with
275
288
  the auto-imported `useResultView`, adds only its own bespoke body (the security findings), and
@@ -32,5 +32,11 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
32
32
  // projection would fork the rule the moment the default changes.
33
33
  ...(p.tier ? { tier: p.tier } : {}),
34
34
  ...(p.resultView ? { resultView: p.resultView } : {}),
35
+ // Not part of `presentation` on the wire — it is a fact about how the kind RUNS, projected
36
+ // beside `container` — so it is lifted from the entry itself. Carried onto the archetype
37
+ // because the pipeline builder resolves a step's meta through `agentKindMeta`, not through
38
+ // the snapshot, and a required step option cannot be gated on something the read model
39
+ // does not carry.
40
+ ...(kind.binaryOutput ? { binaryOutput: true } : {}),
35
41
  }
36
42
  }
@@ -310,6 +310,7 @@ describe('nav grouping helpers', () => {
310
310
  'infrastructure',
311
311
  'workspaceContext',
312
312
  'configuration',
313
+ 'help',
313
314
  ])
314
315
  // The model layer is its own section, ahead of the optional integrations: the engines,
315
316
  // the per-agent model choice (beside the providers it picks from, rather than under
@@ -332,6 +333,12 @@ describe('nav grouping helpers', () => {
332
333
  'operator-dashboard',
333
334
  'reports',
334
335
  ])
336
+ // The tail section: what teaches the product, rather than what configures it. It is on
337
+ // the sidebar at all because the launch prompt is answered once and the palette was then
338
+ // the only route back to the walkthroughs — asking the user least likely to have found
339
+ // the palette to find it.
340
+ const help = groups.find((g) => g.group === 'help')
341
+ expect(help?.items.map((i) => i.id)).toEqual(['tutorial'])
335
342
  })
336
343
 
337
344
  it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
@@ -1,5 +1,4 @@
1
1
  import { defineModule } from '@modular-vue/core'
2
- import { resolveTours } from '~/utils/tutorial'
3
2
  import { filterExternalTools } from './external-tools'
4
3
  import type { AppSlots } from './slots'
5
4
 
@@ -47,6 +46,11 @@ export type NavSurface = 'sidebar' | 'command' | 'toolbar'
47
46
  * dropped entirely where none are registered. It is separate from `integrations` for the same
48
47
  * reason `models` is — an integration is a system cat-factory READS FROM or WRITES TO on your
49
48
  * behalf, while these are places a person GOES.
49
+ *
50
+ * `help` is the tail section: surfaces that teach the product rather than configure it. It is
51
+ * separate from `configuration` because nothing in it changes what a run does — and it is a
52
+ * SIDEBAR section rather than a palette-only entry because the people it serves are the ones
53
+ * least likely to know the palette exists.
50
54
  */
51
55
  export type NavSidebarGroup =
52
56
  | 'create'
@@ -57,6 +61,7 @@ export type NavSidebarGroup =
57
61
  | 'workspaceContext'
58
62
  | 'externalTools'
59
63
  | 'configuration'
64
+ | 'help'
60
65
 
61
66
  /** Command-palette group (its i18n label is `layout.commandBar.groups.<group>`). */
62
67
  export type NavCommandGroup =
@@ -539,21 +544,27 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
539
544
  sidebar: { group: 'configuration', order: 45 },
540
545
  },
541
546
  {
542
- // Deliberately NOT `advanced`: the tours exist for exactly the users basic mode serves,
543
- // and the palette entry is the way back to them after the launch prompt was declined
544
- // or dismissed. Ungated: every tour gates itself via its own `when` predicate, and the
545
- // prompt is worth reaching even when it can only list some tours.
547
+ // Deliberately NOT `advanced`: the tutorials exist for exactly the users basic mode
548
+ // serves. It is on the SIDEBAR as well as in the palette because the launch prompt is
549
+ // shown once and answered once after that, a palette entry was the only route back to
550
+ // the walkthroughs, which asks the user least likely to have found the palette to find
551
+ // it. Ungated: every tour answers for itself (an unavailable one is listed with what
552
+ // would unlock it), so the catalogue is worth reaching even when little of it can run.
546
553
  id: 'tutorial',
547
- labelKey: 'layout.commandBar.cmd.tutorial',
554
+ labelKey: 'nav.tutorials',
548
555
  icon: 'i-lucide-graduation-cap',
549
- surfaces: S('command'),
556
+ surfaces: S('sidebar', 'command'),
550
557
  action: 'tutorial',
551
558
  testId: 'nav-tutorial',
559
+ sidebar: { group: 'help', order: 10 },
552
560
  command: {
553
561
  // After the pre-slice-1 tail (the workspace group pins that order): genuinely new
554
562
  // entries append rather than interleave.
555
563
  group: 'workspace',
556
564
  order: 100,
565
+ // No `labelKey` override: the palette shows "Tutorials" too, so an override would be a
566
+ // second key holding the same word in ten locales — two places to keep in step and one
567
+ // of them free to drift. `layout.commandBar.cmd.tutorial` was deleted with it.
557
568
  keywordsKey: 'layout.commandBar.keywords.tutorial',
558
569
  },
559
570
  },
@@ -615,7 +626,6 @@ export const navigationModule = defineModule({
615
626
  export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppSlots {
616
627
  const gates = deps.gates
617
628
  const nav = slots.nav ?? []
618
- const tutorialTours = slots.tutorialTours ?? []
619
629
  const externalTools = slots.externalTools ?? []
620
630
  return {
621
631
  ...slots,
@@ -626,11 +636,12 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
626
636
  (i) => (i.advanced ? gates.advancedMode : true) && (i.gate ? i.gate(gates) : true),
627
637
  )
628
638
  : nav,
629
- // Tutorial tours gate over the same reactive service, so a tour about a surface the
630
- // caller can't reach (e.g. creating tasks without board write) never shows, and a step
631
- // about a branch this board isn't on is dropped rather than skipped (see `resolveTours`).
632
- // Same gates-absent pass-through as `nav`.
633
- tutorialTours: gates ? resolveTours(tutorialTours, gates) : tutorialTours,
639
+ // `tutorialTours` is deliberately NOT filtered here, unlike every other gated slot. A
640
+ // `SlotFilter` can only DROP, and the tutorial catalogue's whole job is to explain what
641
+ // was dropped which tour this board can't run yet, and what would unlock it. That is a
642
+ // richer value than a thinned list, so tour resolution lives in `resolveTourCatalogue`
643
+ // (pure, gates-nullable) and runs once in `useTutorialTours`, whose `tours` is the same
644
+ // gated set the launch prompt and the overlay always saw.
634
645
  // External tools gate on the same two axes as `nav` — they become nav items downstream
635
646
  // (`useNavContributions` projects them), so gating them anywhere else would let a tool the
636
647
  // caller can't use reach the palette while its sidebar twin was correctly hidden.
@@ -650,6 +661,7 @@ export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
650
661
  // configuration tail: they are destinations someone reaches mid-work, not settings.
651
662
  'externalTools',
652
663
  'configuration',
664
+ 'help',
653
665
  ]
654
666
 
655
667
  /** Command-palette groups, in render order; each label is `layout.commandBar.groups.<group>`. */
@@ -37,8 +37,11 @@ import type { WorkspaceMetadataFieldDefinition } from './workspace-metadata'
37
37
  * - `tutorialTours` — the in-app tutorial catalog ({@link TutorialTour}: data-only
38
38
  * guided tours anchored to `data-testid`s, no components). First-party tours come
39
39
  * from `modular/tutorial-tours.ts`; a consumer contributes its own to the same slot
40
- * and they appear in the launch prompt beside the built-ins, gated per tour by its
41
- * `when(gates)` predicate in the same reactive `slotFilter` that gates `nav`.
40
+ * and they appear in the launch prompt and the tutorial catalogue beside the built-ins.
41
+ * The one gated slot `navSlotFilter` does NOT filter: a tour's `requires` is resolved by
42
+ * `resolveTourCatalogue` in `useTutorialTours` instead, because the catalogue must list
43
+ * the tours this board can't run yet WITH what would unlock them — an annotation a
44
+ * slots-to-slots filter cannot carry.
42
45
  * - `externalTools` — the deployment's OWN web applications, listed in their own
43
46
  * "External tools" sidebar section ({@link ExternalToolContribution}). Each entry resolves
44
47
  * its URL from the invocation context (user, workspace, the custom metadata below), so the
@@ -1,9 +1,15 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
1
3
  import { describe, expect, it } from 'vitest'
2
4
  import en from '../../i18n/locales/en.json'
3
- import { TUTORIAL_TOURS, tutorialToursModule } from '~/modular/tutorial-tours'
4
- import { NAV_CONTRIBUTIONS, navSlotFilter } from '~/modular/nav-contributions'
5
+ import {
6
+ TUTORIAL_REQUIREMENTS,
7
+ TUTORIAL_TOURS,
8
+ tutorialToursModule,
9
+ } from '~/modular/tutorial-tours'
10
+ import { resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
5
11
  import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
6
- import type { AppSlots, NavGates } from '~/modular/nav-contributions'
12
+ import type { NavGates } from '~/modular/nav-contributions'
7
13
 
8
14
  const ALL_GATES: NavGates = {
9
15
  canWriteBoard: true,
@@ -38,12 +44,6 @@ const FRESH_BOARD: NavGates = {
38
44
  boardHasFinishedRun: false,
39
45
  }
40
46
 
41
- const slots = (): AppSlots =>
42
- ({
43
- nav: [...NAV_CONTRIBUTIONS],
44
- tutorialTours: [...TUTORIAL_TOURS],
45
- }) as unknown as AppSlots
46
-
47
47
  /** Resolve a dot-path against the en catalog; undefined when any hop is missing. */
48
48
  function lookupKey(key: string): unknown {
49
49
  return key
@@ -51,6 +51,72 @@ function lookupKey(key: string): unknown {
51
51
  .reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en)
52
52
  }
53
53
 
54
+ /**
55
+ * The layer's srcDir. Anchored on the package directory rather than on `import.meta.url`,
56
+ * which under the happy-dom test environment is not a `file:` URL at all.
57
+ */
58
+ const SRC_DIR = join(process.cwd(), 'app')
59
+
60
+ /**
61
+ * The two ways this layer names a test id, both of which a tour may legitimately anchor on.
62
+ *
63
+ * - written straight onto an element, in either quoting style, including the bound form
64
+ * (`:data-testid="'foo'"`);
65
+ * - declared as a `testId` field on a DATA contribution — the nav catalog's items carry one
66
+ * and `SideBar.vue` renders it as `:data-testid="item.testId"`, which is how the whole
67
+ * `nav-*` family (`nav-add-from-repo` among them) reaches the DOM.
68
+ *
69
+ * A template literal (`` `tutorial-start-${id}` ``) matches neither, which is correct: that is
70
+ * not an id, it is a family of them, and no built-in step anchors on one.
71
+ */
72
+ const ID_PATTERNS = [
73
+ /data-testid\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
74
+ /\btestId\s*:\s*(?:'([^']*)'|"([^"]*)")/g,
75
+ ]
76
+
77
+ /**
78
+ * Every `.vue`/`.ts` file the layer SHIPS. Test sources are excluded in both spellings: an id
79
+ * that exists only in a spec or a fixture is not rendered by anything, so counting one would
80
+ * let the guard pass on a tour anchored to a control that no longer exists.
81
+ */
82
+ function walk(dir: string): string[] {
83
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
84
+ const path = join(dir, entry.name)
85
+ if (entry.isDirectory()) return walk(path)
86
+ return /\.(vue|ts)$/.test(entry.name) && !/\.(spec|test)\.ts$/.test(entry.name) ? [path] : []
87
+ })
88
+ }
89
+
90
+ /** Every anchor the built-in catalog declares, labelled with the step that declares it. */
91
+ function declaredAnchors(): { label: string; id: string }[] {
92
+ const out: { label: string; id: string }[] = []
93
+ for (const tour of TUTORIAL_TOURS) {
94
+ for (const s of tour.steps) {
95
+ for (const id of [s.target, ...(s.altTargets ?? [])]) {
96
+ if (id !== undefined) out.push({ label: `${tour.id}/${s.id}: ${id}`, id })
97
+ }
98
+ }
99
+ }
100
+ return out
101
+ }
102
+
103
+ /** Every static test id this layer actually renders. */
104
+ function renderedTestIds(): Set<string> {
105
+ const ids = new Set<string>()
106
+ for (const file of walk(SRC_DIR)) {
107
+ // The catalog itself declares the ids under test; counting it would make this vacuous.
108
+ if (file.endsWith(join('modular', 'tutorial-tours.ts'))) continue
109
+ const source = readFileSync(file, 'utf8')
110
+ for (const pattern of ID_PATTERNS) {
111
+ for (const match of source.matchAll(pattern)) {
112
+ const raw = (match[1] ?? match[2] ?? '').trim().replace(/^['"]|['"]$/g, '')
113
+ if (isSafeTargetId(raw)) ids.add(raw)
114
+ }
115
+ }
116
+ }
117
+ return ids
118
+ }
119
+
54
120
  describe('the built-in tutorial tour catalog', () => {
55
121
  it('has unique tour ids and unique step ids within each tour', () => {
56
122
  const tourIds = TUTORIAL_TOURS.map((t) => t.id)
@@ -92,91 +158,161 @@ describe('the built-in tutorial tour catalog', () => {
92
158
  })
93
159
 
94
160
  it('names plain data-testid values as targets, never selectors', () => {
95
- for (const tour of TUTORIAL_TOURS) {
96
- for (const s of tour.steps) {
97
- for (const target of [s.target, ...(s.altTargets ?? [])]) {
98
- if (target === undefined) continue
99
- // Asserted through the runtime's OWN guard, not a copy of its regex: the overlay
100
- // drops an id this rejects, so a built-in tour that tripped it would silently
101
- // lose the step rather than fail here.
102
- expect(isSafeTargetId(target), `${tour.id}/${s.id}: ${target}`).toBe(true)
103
- }
104
- }
161
+ for (const anchor of declaredAnchors()) {
162
+ // Asserted through the runtime's OWN guard, not a copy of its regex: the overlay
163
+ // drops an id this rejects, so a built-in tour that tripped it would silently
164
+ // lose the step rather than fail here.
165
+ expect(isSafeTargetId(anchor.id), anchor.label).toBe(true)
105
166
  }
106
167
  })
107
168
 
169
+ it('anchors every step on a data-testid this layer actually renders', () => {
170
+ // The drift guard. A tour's anchors are the ONE thing about it that nothing else in the
171
+ // build checks: a renamed `data-testid` passes typecheck, lint and the whole e2e suite,
172
+ // and five of the ids below (`nav-add-from-repo`, `add-service-repo-search`,
173
+ // `add-service-submit`, `pipeline-picker-trigger`, `inspector-merge-pr`) have no other
174
+ // consumer at all — the tour is the only thing that names them.
175
+ //
176
+ // The failure it prevents is worse than a dead step. None of those steps carries a `when`,
177
+ // so `unexpectedlySkippedSteps` counts the miss and EVERY user who takes that tour lands on
178
+ // a permanent "you missed N steps" notice: the tour would go on making a false claim about
179
+ // itself, in production, with nothing red anywhere.
180
+ //
181
+ // Scoped to the built-in catalog on purpose — a consumer deployment's tours anchor on
182
+ // controls that live in ITS layer, which this repo cannot see and must not fail over.
183
+ const rendered = renderedTestIds()
184
+ // Guard the guard: a scan that silently matched nothing would pass every assertion below.
185
+ expect(rendered.size).toBeGreaterThan(100)
186
+
187
+ const missing = declaredAnchors()
188
+ .filter((anchor) => !rendered.has(anchor.id))
189
+ .map((anchor) => anchor.label)
190
+ expect(missing).toEqual([])
191
+ })
192
+
108
193
  it('is contributed to the tutorialTours slot by the module', () => {
109
194
  expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
110
195
  })
111
196
  })
112
197
 
113
- describe('navSlotFilter over tutorialTours', () => {
114
- it('keeps every tour for a fully-gated user', () => {
115
- const filtered = navSlotFilter(slots(), { gates: ALL_GATES })
116
- expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
198
+ describe('tour availability across the catalog', () => {
199
+ /** The ids a board can START right now — what the launch prompt offers. */
200
+ const ready = (gates: NavGates) => resolveTours(TUTORIAL_TOURS, gates).map((t) => t.id)
201
+ /** The catalogue's own view: every tour, with what is holding each one back. */
202
+ const entry = (gates: NavGates, tourId: string) =>
203
+ resolveTourCatalogue(TUTORIAL_TOURS, gates).find((e) => e.tour.id === tourId)
204
+
205
+ it('offers every tour to a fully-gated user on a fully-populated board', () => {
206
+ expect(ready(ALL_GATES)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
117
207
  })
118
208
 
119
- it('drops the task-creating tour for a read-only viewer', () => {
209
+ it('lists the whole catalog whatever the gates say, holding back rather than hiding', () => {
210
+ // The catalogue surface's contract. A fresh board can run two of the six walkthroughs;
211
+ // dropping the other four (all a slot filter could do) would misrepresent the product as
212
+ // shipping two, to exactly the user who came looking for the rest.
213
+ const catalogue = resolveTourCatalogue(TUTORIAL_TOURS, FRESH_BOARD)
214
+ expect(catalogue.map((e) => e.tour.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
215
+ expect(catalogue.filter((e) => e.availability === 'ready').map((e) => e.tour.id)).toEqual([
216
+ 'board-basics',
217
+ 'add-service',
218
+ ])
219
+ })
220
+
221
+ it('holds the task-creating tour back from a read-only viewer, and says why', () => {
120
222
  const viewer: NavGates = { ...ALL_GATES, canWriteBoard: false }
121
- const filtered = navSlotFilter(slots(), { gates: viewer })
122
- const ids = filtered.tutorialTours.map((t) => t.id)
123
- expect(ids).toContain('board-basics')
124
- expect(ids).not.toContain('first-task')
223
+ expect(ready(viewer)).toContain('board-basics')
224
+ expect(ready(viewer)).not.toContain('first-task')
225
+ expect(entry(viewer, 'first-task')?.unmet.map((r) => r.id)).toEqual(['board-write'])
125
226
  })
126
227
 
127
- it('drops the task-creating tour on a board with no service to add a task to', () => {
228
+ it('holds the task-creating tour back on a board with no service to add a task to', () => {
128
229
  // Every targeted step of that tour would time out in turn and it would then claim to
129
230
  // have taught the core loop; `board-basics` is what an empty board can deliver.
130
231
  const emptyBoard: NavGates = { ...ALL_GATES, boardHasService: false }
131
- const filtered = navSlotFilter(slots(), { gates: emptyBoard })
132
- expect(filtered.tutorialTours.map((t) => t.id)).not.toContain('first-task')
232
+ expect(ready(emptyBoard)).not.toContain('first-task')
233
+ expect(entry(emptyBoard, 'first-task')?.unmet.map((r) => r.id)).toEqual(['service'])
133
234
  })
134
235
 
135
236
  it('offers a brand-new board the orientation tour AND the way out of being empty', () => {
136
237
  // The state the launch prompt actually auto-opens in. Orientation alone would leave a
137
238
  // new workspace with a tour of an empty canvas and no route to a first service, which
138
239
  // is what `add-service` exists to fix — so it must survive exactly this gate set.
139
- const filtered = navSlotFilter(slots(), { gates: FRESH_BOARD })
140
- expect(filtered.tutorialTours.map((t) => t.id)).toEqual(['board-basics', 'add-service'])
240
+ expect(ready(FRESH_BOARD)).toEqual(['board-basics', 'add-service'])
141
241
  })
142
242
 
143
- it('drops the repo tour when no source-control connection can list repositories', () => {
243
+ it('names the missing connection when no source control can list repositories', () => {
144
244
  const noSource: NavGates = { ...FRESH_BOARD, githubAvailable: false }
145
- expect(navSlotFilter(slots(), { gates: noSource }).tutorialTours.map((t) => t.id)).toEqual([
146
- 'board-basics',
147
- ])
245
+ expect(ready(noSource)).toEqual(['board-basics'])
246
+ expect(entry(noSource, 'add-service')?.unmet.map((r) => r.id)).toEqual(['source-control'])
148
247
  })
149
248
 
150
249
  it('offers the run tour once a task exists, and the review tour once a run finished', () => {
151
250
  const withTask: NavGates = { ...FRESH_BOARD, boardHasService: true, boardHasTask: true }
152
- expect(navSlotFilter(slots(), { gates: withTask }).tutorialTours.map((t) => t.id)).toContain(
153
- 'run-task',
154
- )
155
- expect(
156
- navSlotFilter(slots(), { gates: withTask }).tutorialTours.map((t) => t.id),
157
- ).not.toContain('review-merge')
251
+ expect(ready(withTask)).toContain('run-task')
252
+ expect(ready(withTask)).not.toContain('review-merge')
253
+ expect(entry(withTask, 'review-merge')?.unmet.map((r) => r.id)).toEqual(['finished-run'])
158
254
 
159
255
  const finished: NavGates = { ...withTask, boardHasRun: true, boardHasFinishedRun: true }
160
- expect(navSlotFilter(slots(), { gates: finished }).tutorialTours.map((t) => t.id)).toContain(
161
- 'review-merge',
162
- )
256
+ expect(ready(finished)).toContain('review-merge')
257
+ })
258
+
259
+ it('names every unmet requirement, not just the first', () => {
260
+ // The reader has to do all of them; reporting one at a time turns unblocking a tour into
261
+ // a guessing game with a fresh answer after each attempt.
262
+ const bare: NavGates = { ...FRESH_BOARD, canWriteBoard: false, githubAvailable: false }
263
+ expect(entry(bare, 'add-service')?.unmet.map((r) => r.id)).toEqual([
264
+ 'board-write',
265
+ 'source-control',
266
+ ])
267
+ })
268
+
269
+ it('resolves every requirement copy key against the en catalog', () => {
270
+ // Same tier-2 i18n guard as the tour copy above: a requirement's label is looked up from
271
+ // data, so a renamed key would reach the user as a raw path in the "available once" list.
272
+ for (const requirement of Object.values(TUTORIAL_REQUIREMENTS)) {
273
+ expect(typeof lookupKey(requirement.labelKey), requirement.labelKey).toBe('string')
274
+ }
275
+ })
276
+
277
+ it('gives every tour a step that always applies, so none can be listed then not run', () => {
278
+ // The authoring rule `resolveTourCatalogue` documents. `blocked` outranks `not-applicable`,
279
+ // which is right — but it means a tour COULD be named in the catalogue as unlockable and
280
+ // then, once the reader has gone and done the thing it asked for, resolve to no applicable
281
+ // steps and still refuse to start. An unconditional step (every built-in has an intro and a
282
+ // finish card) makes `steps` non-empty under any gates, so that outcome is unreachable.
283
+ for (const tour of TUTORIAL_TOURS) {
284
+ const unconditional = tour.steps.filter((s) => !s.when).map((s) => s.id)
285
+ expect(unconditional, tour.id).not.toEqual([])
286
+ }
287
+ })
288
+
289
+ it('declares its requirements from the shared set', () => {
290
+ // A tour with an inline requirement object is not wrong, but a duplicate of a shared one
291
+ // is: two copies of "a service on the board" drift into two different sentences about the
292
+ // same gate. Pinning the built-ins to the table keeps that a deliberate act.
293
+ const shared = new Set(Object.values(TUTORIAL_REQUIREMENTS).map((r) => r.id))
294
+ for (const tour of TUTORIAL_TOURS) {
295
+ for (const requirement of tour.requires ?? []) {
296
+ expect(shared, `${tour.id}: ${requirement.id}`).toContain(requirement.id)
297
+ }
298
+ }
163
299
  })
164
300
 
165
301
  it('passes tours through untouched when no gates service is wired', () => {
166
- const filtered = navSlotFilter(slots(), {})
167
- expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
302
+ expect(resolveTours(TUTORIAL_TOURS, null).map((t) => t.id)).toEqual(
303
+ TUTORIAL_TOURS.map((t) => t.id),
304
+ )
168
305
  })
169
306
  })
170
307
 
171
308
  describe('the parked-run tour branches', () => {
172
309
  const stepIds = (gates: NavGates, tourId: string) =>
173
- navSlotFilter(slots(), { gates })
174
- .tutorialTours.find((t) => t.id === tourId)
310
+ resolveTours(TUTORIAL_TOURS, gates)
311
+ .find((t) => t.id === tourId)
175
312
  ?.steps.map((s) => s.id)
176
313
 
177
314
  it('is not offered while nothing is waiting for a human', () => {
178
- const ids = navSlotFilter(slots(), { gates: FRESH_BOARD }).tutorialTours.map((t) => t.id)
179
- expect(ids).not.toContain('answer-park')
315
+ expect(resolveTours(TUTORIAL_TOURS, FRESH_BOARD).map((t) => t.id)).not.toContain('answer-park')
180
316
  })
181
317
 
182
318
  it('shows the decision branch only, for a run parked on a decision', () => {
@@ -210,7 +346,7 @@ describe('the parked-run tour branches', () => {
210
346
  it('keeps every branch when no gates service is wired', () => {
211
347
  // Same dev-open parity as `nav`: with nothing to gate against, nothing is withheld —
212
348
  // including the per-step branches, which a bare install must not silently thin out.
213
- const answerPark = navSlotFilter(slots(), {}).tutorialTours.find((t) => t.id === 'answer-park')
349
+ const answerPark = resolveTours(TUTORIAL_TOURS, null).find((t) => t.id === 'answer-park')
214
350
  expect(answerPark?.steps.map((s) => s.id)).toEqual([
215
351
  'intro',
216
352
  'resolve',