+
+
+ Clicks: {{ count }}
+
+
+
diff --git a/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts b/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts
new file mode 100644
index 00000000..35f7ccec
--- /dev/null
+++ b/apps/app/tests/playwright-ct/components/sanity-vuetify.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/experimental-ct-vue'
+import SanityButtonHarness from './SanityButtonHarness.vue'
+
+// B2 sanity — proves the full provider stack from playwright/index.ts
+// is wired:
+// - Vuetify renders v-btn with theme tokens applied
+// - Click events propagate via Vue's reactivity (counter ref updates)
+// - Vuetify CSS variables resolve in computed style
+//
+// TEMPORARY VUETIFY: this test is replaced by a PrimeVue equivalent
+// in F3. Do not extend or generalise — F3 rewrites it. See
+// dev-docs/ARCH-TESTING.md §6.
+//
+// Why a .vue harness file: Playwright CT runs the test orchestrator
+// in Node and the component in a Vite-bundled browser context. Vue
+// components that pull in CSS-side-effect imports (Vuetify) cannot be
+// loaded directly into the test's Node module graph; they must live
+// in a .vue / Vite-compilable file. This is a structural divergence
+// from Vitest, which uses jsdom and one module graph for both.
+
+test.describe('B2 sanity: provider stack', () => {
+ test('mounts a Vuetify v-btn and propagates clicks', async ({ mount }) => {
+ const component = await mount(SanityButtonHarness)
+ const btn = component.locator('[data-test="btn"]')
+
+ await expect(btn).toBeVisible()
+ await expect(btn).toContainText('Clicks: 0')
+
+ await btn.click()
+ await expect(btn).toContainText('Clicks: 1')
+
+ await btn.click()
+ await expect(btn).toContainText('Clicks: 2')
+ })
+
+ test('Vuetify primary theme color resolves on rendered button', async ({ mount, page }) => {
+ await mount(SanityButtonHarness)
+
+ // Vuetify exposes theme primary as "R, G, B" decimals on
+ // --v-theme-primary (e.g. "31, 122, 209" for #1f7ad1).
+ const themePrimary = await page.evaluate(() => {
+ const root = document.documentElement
+
+ return getComputedStyle(root).getPropertyValue('--v-theme-primary').trim()
+ })
+
+ expect(themePrimary).not.toBe('')
+ expect(themePrimary.split(',')).toHaveLength(3)
+ })
+})
diff --git a/apps/app/tests/playwright-ct/utils/mountWithProviders.ts b/apps/app/tests/playwright-ct/utils/mountWithProviders.ts
new file mode 100644
index 00000000..8aabd5ba
--- /dev/null
+++ b/apps/app/tests/playwright-ct/utils/mountWithProviders.ts
@@ -0,0 +1,123 @@
+import type { Locator } from '@playwright/test'
+import type { Component } from 'vue'
+
+import type { HooksConfig } from '../../../playwright'
+
+/**
+ * mountWithProviders — Playwright Component Testing analogue of
+ * tests/utils/mountWithVuexy.ts.
+ *
+ * Why two helpers?
+ * ----------------
+ * Playwright CT's `mount()` API is structurally different from
+ * @vue/test-utils' `mount()`: provider plugins (Vuetify, Pinia,
+ * Router, VueQuery) must be registered in `playwright/index.ts`'s
+ * `beforeMount` hook rather than passed at call time. This helper
+ * is a thin ergonomic wrapper that:
+ *
+ * - Forwards `hooksConfig` to the beforeMount hook (typed via
+ * HooksConfig from playwright/index.ts)
+ * - Returns the standard Playwright Locator from CT's mount, so
+ * downstream test code uses normal Playwright assertions
+ * (component.click(), expect(component).toBeVisible(), etc.)
+ * - Serves as a single, discoverable surface for "how do I mount
+ * a component in this codebase" questions
+ *
+ * Lifecycle note (TEMPORARY VUETIFY):
+ * -----------------------------------
+ * The Vuetify plugin line in `playwright/index.ts`'s `beforeMount`
+ * hook is INTENTIONALLY temporary state. F3 (PrimeVue foundation,
+ * RFC-WS-FRONTEND-PRIMEVUE §6) replaces it with PrimeVue. We do NOT
+ * abstract behind a "pluggable UI framework" indirection because:
+ *
+ * 1. We are NOT retaining Vuetify; the abstraction would itself
+ * need to be removed in F3.
+ * 2. The swap is mechanical (~2-hour) and atomic; abstraction adds
+ * cognitive cost without paying back.
+ * 3. Reviewers seeing "Vuetify in test infra in a PrimeVue migration
+ * sprint" should read this JSDoc and dev-docs/ARCH-TESTING.md §6
+ * for context.
+ *
+ * Equivalence to mountWithVuexy.ts:
+ * ---------------------------------
+ * | Capability | Vitest (mountWithVuexy) | Playwright CT (this) |
+ * |----------------------------------|-------------------------------|----------------------|
+ * | Vuetify w/ tokens | createVuetify({components,…}) | beforeMount hook |
+ * | Pinia (actions execute) | createTestingPinia | createPinia |
+ * | TanStack Query (fresh client) | per-call new QueryClient | per-test in hook |
+ * | Memory-history router | per-call createRouter | per-test in hook |
+ * | initialPath / initialQuery | options.initialPath | hooksConfig.… |
+ * | Initial Pinia state | options.initialState | hooksConfig.piniaInitialState |
+ * | Notification mock | createNotificationMock + plug | (see assertNotification below) |
+ *
+ * Notification assertions:
+ * ------------------------
+ * Playwright runs in a separate Node process from the browser and
+ * cannot use `vi.fn()` spies on store actions. Instead, tests assert
+ * on the rendered UI (e.g. `await expect(page.getByRole('alert'))
+ * .toContainText('Saved')`) or read pinia state via page.evaluate.
+ * This is a deliberate divergence from the Vitest pattern — UI
+ * assertions are stronger than spy assertions for a real-browser
+ * runner.
+ *
+ * Type signature constraint:
+ * --------------------------
+ * Playwright CT's MountResult uses internal types. We accept the
+ * native MountOptions shape and wrap; tests should import the
+ * Playwright-CT `expect`/`test` and call `mountWithProviders` to
+ * get the locator back.
+ */
+export interface MountOptions {
+ props?: Record