Skip to content

Commit 43bed6b

Browse files
authored
fix(e2e): navigate the router, not the URL fragment (#596)
The path-routing migration (e225828) left the workflow suite navigating by `location.hash`. Under `createWebHistory` that still changes the URL and still fires `hashchange`, but the router does not listen to it, so the route never changed and nothing threw. Every caller failed much later on a missing `.secret-list-item`, which read as a broken vault rather than a navigation that did nothing at all. `gotoVaultRoute` now pushes through the router instance, with a pushState + popstate fallback that drives `createWebHistory`'s own listener. Both keep the navigation in place, which the vault requires: the CryptoKey lives only in memory, so any reload drops it and the guard bounces to the lock gate. `openVault` delegates to the same helper instead of carrying a second, also hash-shaped copy of the logic, and the two specs that set `location.hash` inline now call the helper.
1 parent f2ef7fb commit 43bed6b

4 files changed

Lines changed: 58 additions & 47 deletions

File tree

tests/e2e/workflows/_workflow-helpers.ts

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -165,32 +165,14 @@ export async function unlockVault(
165165
}
166166

167167
/**
168-
* Open the vault list WITHIN the unlocked SPA by clicking the "Vault" nav link
169-
* (a vue-router-link). A full `page.goto` reload would wipe the in-memory
170-
* CryptoKey and bounce to the lock screen; a router-link click navigates the
171-
* SPA in place and keeps the session unlocked. The click is dispatched natively
172-
* because the themed nav entry can swallow Playwright's synthetic click.
168+
* Open the vault list WITHIN the unlocked SPA. A full `page.goto` reload would
169+
* wipe the in-memory CryptoKey and bounce to the lock screen, so this navigates
170+
* the router in place and the session stays unlocked.
173171
*
174172
* @param page The Playwright page (must already be unlocked).
175173
*/
176174
export async function openVault(page: Page): Promise<void> {
177-
// The router runs in hash mode (createWebHashHistory / mode:'hash'), so the
178-
// in-app Vault route is `#/secrets`. Click the manifest nav entry whose href
179-
// is the hash route; fall back to an in-place `location.hash` navigation
180-
// (which does NOT reload the page, so the in-memory CryptoKey survives and
181-
// the vault stays unlocked, unlike a full `page.goto`).
182-
await page.evaluate(() => {
183-
const a = Array.from(document.querySelectorAll('a')).find((x) =>
184-
/(#\/secrets$)|(\/apps\/keepiq\/?#\/secrets$)/.test(
185-
x.getAttribute('href') || '',
186-
),
187-
)
188-
if (a) {
189-
;(a as HTMLElement).click()
190-
} else if (!/#\/secrets$/.test(window.location.hash)) {
191-
window.location.hash = '#/secrets'
192-
}
193-
})
175+
await gotoVaultRoute(page, 'secrets')
194176
await expect(page.locator('.secret-list-view')).toBeVisible({ timeout: 20_000 })
195177
}
196178

@@ -240,22 +222,55 @@ export async function clickOverflowAction(
240222
/**
241223
* Navigate to an in-app route WITHIN the already-unlocked SPA, in place.
242224
*
243-
* The router runs in hash mode, so routes are `#/<route>`. A full `page.goto`
244-
* to a path-form URL (e.g. `/apps/keepiq/secrets`) reloads the page, which
245-
* wipes the in-memory CryptoKey and bounces back to the lock gate. Setting
246-
* `location.hash` navigates the SPA in place and keeps the vault unlocked.
225+
* ⚠️ This MUST NOT reload the page. The vault's CryptoKey lives only in memory,
226+
* so a `page.goto` to any in-app route drops it and the router guard bounces
227+
* straight back to the lock gate.
228+
*
229+
* The router moved from hash mode to `createWebHistory` (clean path URLs), and
230+
* that is why this helper is written against the router instance rather than
231+
* the URL. Under hash mode, `location.hash = '#/secrets'` both changed the URL
232+
* and drove the route. Under path mode the same line still "works" — it appends
233+
* a fragment and fires `hashchange` — but `createWebHistory` does not listen to
234+
* `hashchange`, so the route never changes and NOTHING throws. Every caller
235+
* then failed much later, on a missing `.secret-list-item`, which reads as a
236+
* broken vault rather than a navigation that silently did nothing.
237+
*
238+
* `$router.push` is an in-place SPA navigation, so the key survives. The
239+
* pushState fallback exists only for the case where the app handle is not
240+
* exposed; it drives `createWebHistory`'s own `popstate` listener.
247241
*
248242
* @param page The Playwright page (must already be unlocked).
249-
* @param route The in-app route WITHOUT the leading hash, e.g. 'secrets',
243+
* @param route The in-app route WITHOUT a leading slash, e.g. 'secrets',
250244
* 'password-health', or '' for the dashboard root.
251245
*/
252246
export async function gotoVaultRoute(page: Page, route: string): Promise<void> {
253-
const hash = `#/${route}`.replace(/\/$/, route === '' ? '/' : '')
254-
await page.evaluate((h) => {
255-
window.location.hash = h
256-
}, hash)
257-
// Let the hashchange-driven router transition settle. Polling surfaces never
258-
// reach networkidle, so wait on the DOM instead.
247+
const path = `/${route}`.replace(/\/+$/, '') || '/'
248+
await page.evaluate((p) => {
249+
const host = document.querySelector('#keepiq-app') as
250+
| (HTMLElement & {
251+
__vue_app__?: {
252+
config?: {
253+
globalProperties?: {
254+
$router?: { push: (to: string) => unknown }
255+
}
256+
}
257+
}
258+
})
259+
| null
260+
const router = host?.__vue_app__?.config?.globalProperties?.$router
261+
if (router) {
262+
router.push(p)
263+
return
264+
}
265+
// No app handle: drive createWebHistory's popstate listener directly.
266+
// The base is derived exactly as `routerBase()` in src/main.js does, so
267+
// both the `/apps/` and `/index.php/apps/` URL forms resolve.
268+
const base =
269+
window.location.pathname.match(/^(.*\/apps\/keepiq)(?:\/|$)/)?.[1] ?? ''
270+
window.history.pushState({}, '', `${base}${p}`)
271+
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }))
272+
}, path)
273+
// Polling surfaces never reach networkidle, so wait on the DOM instead.
259274
await page.waitForLoadState('domcontentloaded')
260275
await page.waitForTimeout(500)
261276
}

tests/e2e/workflows/compromise-recovery.spec.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import type { Page } from '@playwright/test'
4646
* @e2e openspec/specs/encryption-suites/spec.md#requirement-suite-migration
4747
*/
4848
import { expect, test } from '@playwright/test'
49-
import { APP_BASE } from './_workflow-helpers.ts'
49+
import { APP_BASE, gotoVaultRoute } from './_workflow-helpers.ts'
5050

5151
/** A fixture account that owns no EncryptionSuite, so setup mode is reachable. */
5252
const VAULT_USER = process.env.KEEPIQ_VAULT_USER ?? 'alice'
@@ -396,9 +396,7 @@ test.describe('Workflow: compromise recovery — encryption-suites/spec.md', ()
396396
timeout: 15_000,
397397
})
398398

399-
await page.evaluate(() => {
400-
window.location.hash = '#/secrets'
401-
})
399+
await gotoVaultRoute(page, 'secrets')
402400
await page.waitForTimeout(2500)
403401

404402
const warned = page.locator('[data-testid="secret-possibly-compromised"]')

tests/e2e/workflows/folder-sharing.spec.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { expect, test } from '@playwright/test'
4343
import {
4444
clickOverflowAction,
4545
gotoLockSettled,
46+
gotoVaultRoute,
4647
openVault,
4748
unlockVault,
4849
} from './_workflow-helpers.ts'
@@ -334,10 +335,7 @@ test.describe('Workflow: folders + sharing — folders/spec.md', () => {
334335
// write; this is the read, and they are not the same claim — the list
335336
// could filter on something else entirely and the row would vanish.
336337
// Navigate in place (a reload would drop the in-memory key).
337-
await page.evaluate((id) => {
338-
window.location.hash = `#/folders/${id}`
339-
}, folder.id)
340-
await page.waitForLoadState('domcontentloaded')
338+
await gotoVaultRoute(page, `folders/${folder.id}`)
341339
await expect(
342340
page.locator('.secret-list-item', { hasText: secretName }),
343341
`"${secretName}" is not listed under the folder it was moved into`,

tests/e2e/workflows/page-surfaces.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ import type { Page } from '@playwright/test'
4646
*
4747
* ROUTING NOTE
4848
* ------------
49-
* The router is in hash mode and the vault's private key lives only in memory,
50-
* so a full `page.goto()` to an in-app route reloads the SPA, discards the
51-
* CryptoKey and lands on the lock gate. Authenticated pages are reached with
52-
* `gotoVaultRoute()` (an in-place `location.hash` change) after one
53-
* `unlockVault()`. The three public recipient routes are exempt from the lock
54-
* guard (`PUBLIC_ROUTE_NAMES` in `src/App.vue`) and are reached with `goto`.
49+
* The vault's private key lives only in memory, so a full `page.goto()` to an
50+
* in-app route reloads the SPA, discards the CryptoKey and lands on the lock
51+
* gate. Authenticated pages are reached with `gotoVaultRoute()` (an in-place
52+
* router navigation) after one `unlockVault()`. The three public recipient
53+
* routes are exempt from the lock guard (`PUBLIC_ROUTE_NAMES` in `src/App.vue`)
54+
* and are reached with `goto`.
5555
*/
5656
import { expect, test } from '@playwright/test'
5757
import { APP_BASE, gotoVaultRoute, unlockVault } from './_workflow-helpers.ts'

0 commit comments

Comments
 (0)