@@ -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 */
176174export 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- / ( # \/ s e c r e t s $ ) | ( \/ a p p s \/ k e e p i q \/ ? # \/ s e c r e t s $ ) / . test (
185- x . getAttribute ( 'href' ) || '' ,
186- ) ,
187- )
188- if ( a ) {
189- ; ( a as HTMLElement ) . click ( )
190- } else if ( ! / # \/ s e c r e t s $ / . 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 */
252246export 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 ( / ^ ( .* \/ a p p s \/ k e e p i q ) (?: \/ | $ ) / ) ?. [ 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}
0 commit comments