@gallopsystems/agent-skills 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gallopsystems/agent-skills",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Gallop Systems Claude Code skills, symlinked into .claude/skills on install.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -482,6 +482,77 @@ for (const tz of timezones) {
482
482
  }
483
483
  ```
484
484
 
485
+ ### 7. Reading or driving a stubbed child needs an explicit stub
486
+
487
+ `Stub: true` (auto-stub) renders the child but **does not expose its props** to
488
+ `findComponent(Stub).props("x")`. To read or drive a child's `modelValue`, give it
489
+ an explicit stub that declares the prop:
490
+
491
+ ```typescript
492
+ const SelectStub = {
493
+ name: "Select",
494
+ props: ["modelValue"],
495
+ emits: ["update:modelValue"],
496
+ template: "<div />",
497
+ };
498
+ // findComponent(SelectStub).props("modelValue") — now readable
499
+ // findComponent(SelectStub).vm.$emit("update:modelValue", x) — drives the v-model
500
+ ```
501
+
502
+ For a deep tree (a dialog full of fields), `shallow: true` auto-stubs everything;
503
+ add explicit stubs only for the parts you assert on — plus a dialog stub that
504
+ renders its slot, since UI-lib dialogs gate content behind a `visible` prop:
505
+
506
+ ```typescript
507
+ const DialogStub = { props: ["visible"], template: "<div v-if='visible'><slot /><slot name='footer' /></div>" };
508
+ ```
509
+
510
+ ### 8. Driving and asserting route/query state
511
+
512
+ A global route middleware that redirects unauthenticated navigations (to a public
513
+ `/login`) means `mountSuspended(Comp, { route: "/private?foo=1" })` lands on the
514
+ public path and **drops the query** — so a `useRouteQuery`-bound ref reads empty.
515
+ Two ways around it:
516
+
517
+ - **Carry the params on the public route:** `route: "/login?foo=1"`. `useRouteQuery`
518
+ binds to `route.query` regardless of the path.
519
+ - **Assert URL *writes* by spying on `router.replace`**, not by reading
520
+ `currentRoute` (the redirect strips what you'd read back):
521
+
522
+ ```typescript
523
+ const replace = vi.spyOn((wrapper.vm as any).$router, "replace");
524
+ input.vm.$emit("update:modelValue", "foo");
525
+ await flushPromises();
526
+ expect(replace.mock.calls.some((c) => (c[0] as any)?.query?.q === "foo")).toBe(true);
527
+ ```
528
+
529
+ ### 9. Testing a composable that needs a component scope
530
+
531
+ For a composable using lifecycle hooks or `provide`/`inject`, mount a throwaway
532
+ component whose `setup()` calls it and capture the return:
533
+
534
+ ```typescript
535
+ let api: ReturnType<typeof useThing>;
536
+ const Comp = defineComponent({ setup() { api = useThing(); return () => h("div"); } });
537
+ await mountSuspended(Comp);
538
+ ```
539
+
540
+ Control a VueUse dependency with `vi.mock("@vueuse/core", …)` to return refs you
541
+ drive. And make sure the vitest `include` glob covers `app/composables/**` — a
542
+ composables dir is easy to leave out of the frontend config.
543
+
544
+ ### 10. Reset the shared `useFetch` cache between mounts
545
+
546
+ `useFetch`/`useAsyncData` cache by key, and the cache is **shared across
547
+ `mountSuspended` calls in a file**. When several tests mount the same component
548
+ (fixed URL) but `registerEndpoint` returns different data per test, the second
549
+ test sees the first's response unless you clear it:
550
+
551
+ ```typescript
552
+ import { clearNuxtData } from "#imports"; // not exported from @nuxt/test-utils/runtime
553
+ beforeEach(() => clearNuxtData());
554
+ ```
555
+
485
556
  ## File Organization
486
557
 
487
558
  Co-locate tests with source files:
@@ -45,6 +45,32 @@ URL sync (`router.replace({ query: { ...route.query, tab } })`) →
45
45
  param. Nuxt's own `useRoute()` is already reactive for *reads*; reach for
46
46
  `useRouteQuery` when you want a **writable** ref bound to a single param.
47
47
 
48
+ ### `useLocalStorage` reads at setup — guard SSR hydration
49
+
50
+ `useLocalStorage`/`useStorage` is SSR-safe: on the server there's no `window`, so
51
+ it returns the default and never touches storage (no `import.meta.client` guard
52
+ needed — that guard was only for raw `localStorage.*` calls, which throw on the
53
+ server). But on the client it reads storage **synchronously at setup**, so a value
54
+ rendered *without a mount gate* differs between the server render (the default) and
55
+ the hydrating client render (the stored value) → a hydration mismatch. Pass
56
+ `{ initOnMounted: true }` to defer the read to `onMounted` so the first client
57
+ render matches the server, or gate the rendering until mounted.
58
+
59
+ ```ts
60
+ // shown straight away (no v-if gate) → defer the storage read to onMounted
61
+ const view = useLocalStorage('view', 'list', { initOnMounted: true })
62
+ ```
63
+
64
+ ### `useRouteQuery` makes the URL the single source of truth
65
+
66
+ Because the bound ref reads from and writes to the query param, the URL *is* the
67
+ state — so it **can't represent a ref with two distinct "empty" states** (e.g. a
68
+ filter that defaults to `"Open"` on load but clears to `null`; both would be "param
69
+ absent"). When you need that distinction, keep a plain `ref` + a projecting `watch`
70
+ (a sanctioned `watch.md` "URL sync" case). And **don't mix `useUrlSearchParams`
71
+ (History API) with `useRouteQuery` (vue-router) in the same component** — they write
72
+ the URL through different mechanisms and clobber each other's params; pick one.
73
+
48
74
  ## VueUse's `watch` sugar — when a `watch` IS warranted
49
75
 
50
76
  When the effect genuinely belongs in a watcher, VueUse's Watch category removes the
@@ -68,9 +68,18 @@ Only when the effect crosses **out of** the reactive graph:
68
68
  polling `setInterval` (clear it in `onUnmounted`).
69
69
  - **Persist** — a `localStorage`/`useCookie` write, debounced auto-save of a
70
70
  deep-watched form.
71
- - **URL sync** — `router.replace({ query: { ...route.query, tab } })`.
71
+ - **URL sync** — `router.replace({ query: { ...route.query, tab } })`. For a single
72
+ ref ↔ one query param, prefer `useRouteQuery` (see below). A write-back watch is
73
+ the right tool only when the URL can't model the state: a ref with **two distinct
74
+ "empty" states** (e.g. a filter that defaults to `"Open"` on load but clears to
75
+ `null` — an absent param can map to only one of them), or a **composite object
76
+ fanning out to many params** (a PrimeVue filter object) that a one-ref-per-param
77
+ `useRouteQuery` can't express.
72
78
  - **Re-seed local state on dialog open** — `watch(visible, (v) => { if (v) initForm() })`.
73
- The single most common legit pattern.
79
+ The single most common legit pattern. Its one-liner is `whenever(visible, initForm)`
80
+ (see `vueuse.md`); a compound guard keeps its inner half —
81
+ `watch(visible, (v) => { if (v && !props.x) reset() })` →
82
+ `whenever(visible, () => { if (!props.x) reset() })`.
74
83
  - **Clone a server prop into a locally-editable draft** —
75
84
  `watch(() => props.record, (r) => { if (r) form.value = structuredClone(toRaw(r)) }, { immediate: true })`.
76
85
 
@@ -130,6 +139,22 @@ watch(activeId, () => scrollActiveIntoView(), { flush: 'post' }) // DOM already
130
139
  | **side-effect-in-handler** | watching a value that only changes via one control, to clear a dependent field | that control's `@update:model-value` handler |
131
140
  | **manual-refetch** | watching filter refs to call a function that calls `useFetch` | a `computed` `query` passed to `useFetch` |
132
141
 
142
+ **An external source doesn't legitimize a deriving watch.** The
143
+ watch-vs-`computed` decision turns on whether the *body* leaves the reactive
144
+ graph — **not** on whether the *source* came from outside it. A watcher whose
145
+ source is a composable/library ref (`useEventSource`'s `status`, a store getter,
146
+ a `useWindowSize`) but whose body only assigns a derived value is still a
147
+ `computed`. "The source is external state" is the most common excuse for keeping
148
+ such a watch, and it's wrong — it's still derivation.
149
+
150
+ ```ts
151
+ // ❌ a library ref as source tempts a watch, but the body only derives
152
+ const isConnected = ref(false)
153
+ watch(status, (s) => { isConnected.value = s === 'OPEN' }) // status from useEventSource
154
+ // ✅ derivation is a computed, regardless of where status came from
155
+ const isConnected = computed(() => status.value === 'OPEN')
156
+ ```
157
+
133
158
  The biggest real cluster was **side-effect-in-handler** — resetting dependent
134
159
  fields when a Select changed. In a watcher it hides cause/effect and re-fires on
135
160
  programmatic form reseeds; the colocated handler is direct and only fires on the