diff --git a/.changeset/web-framework-upgrade.md b/.changeset/web-framework-upgrade.md new file mode 100644 index 0000000..2f3977f --- /dev/null +++ b/.changeset/web-framework-upgrade.md @@ -0,0 +1,5 @@ +--- +'@directory/web': patch +--- + +Upgrade the web app to SvelteKit 3, preserving runtime `site.url`, native sign-in, bookmark navigation and container workflows. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 798de18..2b56294 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -22,9 +22,11 @@ COPY . . RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile # Only generated output and caches are writable; source binds stay read-only. RUN mkdir -p /m/packages/config/dist /m/apps/web/.svelte-kit \ + '/m/apps/web/node_modules/$app' \ /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp \ && chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/packages/config/dist \ - /m/apps/web/.svelte-kit /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp + /m/apps/web/.svelte-kit '/m/apps/web/node_modules/$app' \ + /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp USER ${LOCAL_UID}:${LOCAL_GID} WORKDIR /m/apps/web EXPOSE 5173 diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index 3284ee1..02e3302 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -26,7 +26,6 @@ services: - ./static:/m/apps/web/static:ro - ./config:/m/apps/web/config:ro - ./public:/m/apps/web/public:ro - - ./svelte.config.js:/m/apps/web/svelte.config.js:ro - ./vite.config.ts:/m/apps/web/vite.config.ts:ro - ./tsconfig.json:/m/apps/web/tsconfig.json:ro - ../../packages/config/src:/m/packages/config/src:ro diff --git a/apps/web/e2e/bookmark-logo-fallback.e2e.ts b/apps/web/e2e/bookmark-logo-fallback.e2e.ts index e746735..78a5096 100644 --- a/apps/web/e2e/bookmark-logo-fallback.e2e.ts +++ b/apps/web/e2e/bookmark-logo-fallback.e2e.ts @@ -99,7 +99,9 @@ for (const view of ['card', 'details'] as const) { await page.goto(`${server.url}/${view === 'details' ? '?link=broken' : ''}`, { waitUntil: 'commit' }); - const cardImage = page.locator('a[href="/?link=broken"] img'); + const cardImage = page + .getByRole('link', { name: 'Broken primary', exact: true }) + .locator('img'); await expect(cardImage).toHaveAttribute('src', '/public/primary-a.svg'); await expectBroken(cardImage); await expect.poll(() => heldJavaScript).toBe(true); @@ -266,16 +268,23 @@ test('without JavaScript, good images decode and broken images leave card and de ['default', 'Omitted primary'], ['other', 'Other broken primary'] ]) { - const card = page.locator(`a[href="/?link=${id}"]`); - await expect(card).toContainText(title!); - await expect(card).toHaveAttribute('data-sveltekit-preload-data', 'off'); + const card = page.getByRole('link', { name: title!, exact: true }); + await expect(card).toHaveJSProperty('href', `${server.url}/?link=${id}`); + await expect(card).toHaveAttribute('data-sveltekit-preload-data', 'false'); } - const broken = page.locator('a[href="/?link=broken"] img'); + const brokenLink = page.getByRole('link', { name: 'Broken primary', exact: true }); + const broken = brokenLink.locator('img'); await expect(broken).toHaveAttribute('src', '/public/primary-a.svg'); await expectBroken(broken); - await expectDecoded(page.locator('a[href="/?link=valid"] img'), '/public/valid.svg'); - await expectDecoded(page.locator('a[href="/?link=default"] img'), '/bookmarks/default/logo'); - await page.locator('a[href="/?link=broken"]').click(); + await expectDecoded( + page.getByRole('link', { name: 'Valid primary', exact: true }).locator('img'), + '/public/valid.svg' + ); + await expectDecoded( + page.getByRole('link', { name: 'Omitted primary', exact: true }).locator('img'), + '/bookmarks/default/logo' + ); + await brokenLink.click(); const details = page.getByRole('region', { name: 'Broken primary', exact: true }); await expect(details.locator('img')).toHaveAttribute('src', '/public/primary-a.svg'); await expectBroken(details.locator('img')); @@ -299,9 +308,9 @@ test('without JavaScript, good images decode and broken images leave card and de await expect(url).toBeVisible(); await expect(url).toHaveValue('https://broken.example.test'); await expect(url).toHaveJSProperty('readOnly', true); - await page.locator('a[href="/?link=valid"]').click(); + await page.getByRole('link', { name: 'Valid primary', exact: true }).click(); await expectDecoded(page.locator('section.details img'), '/public/valid.svg'); - await page.locator('a[href="/?link=default"]').click(); + await page.getByRole('link', { name: 'Omitted primary', exact: true }).click(); await expectDecoded(page.locator('section.details img'), '/bookmarks/default/logo'); await page.getByRole('link', { name: 'Back to directory' }).click(); await expect(page).toHaveURL(`${server.url}/`); diff --git a/apps/web/e2e/bookmark-logos.e2e.ts b/apps/web/e2e/bookmark-logos.e2e.ts index 581ea9e..3b0fed2 100644 --- a/apps/web/e2e/bookmark-logos.e2e.ts +++ b/apps/web/e2e/bookmark-logos.e2e.ts @@ -26,14 +26,15 @@ test('bookmark logos render on cards and details without JavaScript and follow a await server.ready(); const page = await context.newPage(); await page.goto(server.url); - const card = page.locator('a[href="/?link=photos"] img'); + const photos = page.getByRole('link', { name: 'Family Photos', exact: true }); + const card = photos.locator('img'); await expect(card).toHaveAttribute('src', '/bookmarks/photos/logo'); await expect(card).toHaveAttribute('width', '64'); await expect(card).toHaveAttribute('height', '64'); await expect .poll(() => card.evaluate((image) => (image as HTMLImageElement).naturalWidth)) .toBeGreaterThan(0); - await page.locator('a[href="/?link=photos"]').click(); + await photos.click(); const details = page.getByRole('region', { name: 'Family Photos', exact: true }); await expect(details.locator('img')).toHaveAttribute('src', '/bookmarks/photos/logo'); expect(await (await server.fetch('/bookmarks/photos/logo')).text()).toContain('>FP'); diff --git a/apps/web/e2e/configured-images.e2e.ts b/apps/web/e2e/configured-images.e2e.ts index e6ede25..ec41751 100644 --- a/apps/web/e2e/configured-images.e2e.ts +++ b/apps/web/e2e/configured-images.e2e.ts @@ -281,7 +281,11 @@ test('open mode serves contained images regardless of references, with MIME and expectPrivateHeaders(response); } } - expect((await server.fetch('/public/photo.svg', { method: 'POST' })).status).toBe(405); + const unsupported = await server.fetch('/public/photo.svg', { + method: 'POST', + headers: { origin: server.origin } + }); + expect(unsupported.status).toBe(405); const generated = await server.fetch('/bookmarks/photos/logo'); expect(generated.status).toBe(200); expectPrivateHeaders(generated); @@ -413,13 +417,13 @@ test('existing SSR cards and details render mounted logos and omitted-path bookm ['router', '/bookmarks/router/logo'], ['thumbnail', '/public/photo.png'] ] as const) { - const image = page.locator(`a[href="/?link=${id}"] img`); + const image = page.locator(`a[href$="?link=${id}"] img`); await expect(image).toHaveAttribute('src', path); await expect .poll(() => image.evaluate((node) => (node as HTMLImageElement).naturalWidth)) .toBeGreaterThan(0); } - await page.locator('a[href="/?link=photos"]').click(); + await page.getByRole('link', { name: 'Photos', exact: true }).click(); const details = page .getByRole('region', { name: 'Photos', exact: true }) .locator('img[src="/public/photo.svg"]'); diff --git a/apps/web/e2e/details-dialog.e2e.ts b/apps/web/e2e/details-dialog.e2e.ts index 8ae540a..22e0617 100644 --- a/apps/web/e2e/details-dialog.e2e.ts +++ b/apps/web/e2e/details-dialog.e2e.ts @@ -172,10 +172,12 @@ test('blocked client boot keeps controls disabled and native grid navigation usa await page.goto(server.url); await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: /theme/ })).toBeDisabled(); - await page.locator('a[href="/?link=beta"]').click(); + const beta = page.getByRole('link', { name: 'Beta service', exact: true }); + await expect(beta).toHaveJSProperty('href', `${server.url}/?link=beta`); + await beta.click(); await expect(page).toHaveURL(`${server.url}/?link=beta`); await expect(page.getByRole('dialog')).toHaveCount(0); - await page.locator('a[href="/?link=alpha"]').click(); + await page.getByRole('link', { name: 'Alpha service', exact: true }).click(); await expect(page).toHaveURL(`${server.url}/?link=alpha`); await expect(page.locator('.tile')).toHaveCount(2); }); diff --git a/apps/web/e2e/directory.e2e.ts b/apps/web/e2e/directory.e2e.ts index 35a8eff..36c921e 100644 --- a/apps/web/e2e/directory.e2e.ts +++ b/apps/web/e2e/directory.e2e.ts @@ -49,15 +49,13 @@ test('groups, bookmark titles and plain details work without JavaScript', async ['router', 'Router'], ['notes', 'Household notes'] ]) { - const link = page.locator(`a[href="/?link=${id}"]`); - await expect(link).toContainText(label!); - await expect(link).toHaveAttribute('data-sveltekit-preload-data', 'off'); + const link = page.getByRole('link', { name: label!, exact: true }); + await expect(link).toHaveJSProperty('href', `${server.url}/?link=${id}`); + await expect(link).toHaveAttribute('data-sveltekit-preload-data', 'false'); } - await expect(page.locator('a[href="/?link=photos"] img')).toHaveAttribute( - 'src', - '/bookmarks/photos/logo' - ); - await page.locator('a[href="/?link=photos"]').click(); + const photos = page.getByRole('link', { name: 'Photos', exact: true }); + await expect(photos.locator('img')).toHaveAttribute('src', '/bookmarks/photos/logo'); + await photos.click(); await expect(page).toHaveURL(`${server.url}/?link=photos`); await expect(page.getByRole('heading', { name: 'Photos', exact: true })).toBeVisible(); const details = page.getByRole('region', { name: 'Photos', exact: true }); @@ -80,7 +78,7 @@ test('groups, bookmark titles and plain details work without JavaScript', async await expect(tags).toHaveCount(1); await expect(tags.getByRole('listitem')).toHaveText(['login', 'internal', 'admin']); await expect(tags.getByRole('listitem').last()).toHaveAccessibleName('admin only'); - await expect(page.locator('a[href="/?link=photos"]')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Photos', exact: true })).toBeVisible(); } finally { await context.close(); expect(await server.stop()).toEqual({ code: 0, signal: null }); diff --git a/apps/web/e2e/search.e2e.ts b/apps/web/e2e/search.e2e.ts index 470ba6e..239d4fe 100644 --- a/apps/web/e2e/search.e2e.ts +++ b/apps/web/e2e/search.e2e.ts @@ -47,6 +47,18 @@ test('search finds a bookmark, opens its details and clears back to browsing', a await expect(results.getByRole('link')).toHaveCount(1); await expect(results.getByRole('link')).toContainText('Family photos'); await expect(page).toHaveURL(`${server.url}/?q=photos`); + // Async shallow routing must not rewrite an in-place edit or move its caret to the end. + await search.press('End'); + await search.press('ArrowLeft'); + await search.press('x'); + await expect(search).toHaveValue('photoxs'); + await expect(page).toHaveURL(`${server.url}/?q=photoxs`); + await expect(search).toBeFocused(); + await expect(search).toHaveJSProperty('selectionStart', 6); + await expect(search).toHaveJSProperty('selectionEnd', 6); + await search.press('Backspace'); + await expect(search).toHaveValue('photos'); + await expect(page).toHaveURL(`${server.url}/?q=photos`); await page.reload(); await expect(search).toHaveValue('photos'); await expect(results.getByRole('link')).toHaveCount(1); diff --git a/apps/web/e2e/site-header-logo.e2e.ts b/apps/web/e2e/site-header-logo.e2e.ts index 6b8653f..c17560d 100644 --- a/apps/web/e2e/site-header-logo.e2e.ts +++ b/apps/web/e2e/site-header-logo.e2e.ts @@ -122,7 +122,7 @@ test('without JavaScript a broken configured header leaves title and native navi await expect(page.locator('header img')).toHaveAttribute('src', sourceA); await expectBroken(page.locator('header img')); await expect(page.locator('header h1')).toHaveText(title); - await page.locator('a[href="/?link=book"]').click(); + await page.locator('a[href$="?link=book"]').click(); await expect(page).toHaveURL(`${app.url}/?link=book`); await expect(page.locator('header .description')).toHaveText(siteDescription); } finally { diff --git a/apps/web/e2e/support/server.ts b/apps/web/e2e/support/server.ts index f4cc615..bf3c09c 100644 --- a/apps/web/e2e/support/server.ts +++ b/apps/web/e2e/support/server.ts @@ -71,8 +71,8 @@ export async function startServer(options: ServerOptions) { const script = ` import { once } from 'node:events'; const { server } = await import(${JSON.stringify(serverModule)}); -if (!server.server.listening) await once(server.server, 'listening'); -process.send({ ready: server.server.address().port }); +if (!server.listening) await once(server, 'listening'); +process.send({ ready: server.address().port }); `; const child = spawn(process.execPath, ['--input-type=module', '--eval', script], { cwd: temporary, diff --git a/apps/web/package.json b/apps/web/package.json index 08bf49f..a8a3afe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,5 +40,8 @@ "unplugin-icons": "catalog:", "vite": "catalog:", "vitest": "catalog:" + }, + "imports": { + "#lib/*": "./src/lib/*" } } diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 2cfb567..fd020c1 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -15,7 +15,6 @@ declare global { } interface PageState { - directoryQuery?: string; directoryDetail?: { id: string; fromGrid: boolean } | null; } diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index 5bab3ce..e61c498 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -1,5 +1,6 @@ +import type { Handle } from '@sveltejs/kit/hooks'; +import type { RequestEvent } from '@sveltejs/kit'; import { validateConfig } from '@directory/config'; -import type { Handle, RequestEvent } from '@sveltejs/kit'; import { themeStyleTag } from './lib/server/appearance.js'; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; import type { Authentication } from './lib/server/auth/index.js'; @@ -14,7 +15,6 @@ const adapterEvents: NodeJS.EventEmitter = process; const mocks = vi.hoisted(() => ({ building: false, dev: false, - env: { ORIGIN: 'https://directory.example.com' }, boot: vi.fn(), report: vi.fn(), load: vi.fn(), @@ -22,8 +22,7 @@ const mocks = vi.hoisted(() => ({ prepare: vi.fn() })); -vi.mock('$env/dynamic/private', () => ({ env: mocks.env })); -vi.mock('$app/environment', () => ({ +vi.mock('$app/env', () => ({ get building() { return mocks.building; }, @@ -172,7 +171,7 @@ describe('server hooks', () => { expect(ready.currentSnapshot).not.toHaveBeenCalled(); pending.resolve(ready); await startup; - expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env); + expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(); expect((await hooks.handle({ event: event(), resolve })).status).toBe(200); adapterEvents.emit('sveltekit:shutdown'); expect((await hooks.handle({ event: event(), resolve })).status).toBe(503); @@ -185,7 +184,7 @@ describe('server hooks', () => { await hooks.init(); expect(development).toHaveBeenCalledOnce(); expect(production).not.toHaveBeenCalled(); - expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env); + expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(); process.emit('directory:dev-stop'); }); @@ -329,9 +328,13 @@ describe('server hooks', () => { for (const cookie of ['', 'unrelated-cookie=opaque']) { for (const [path, routeId, params] of [ ['/public/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }], + ['/public/site.svg', '/(public)/public/[...file]', { file: 'site.svg' }], + ['/%70ublic/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }], + ['/public/unknown.svg', '/(public)/public/[...file]', { file: 'unknown.svg' }], + [ '/bookmarks/photos/logo', '/(protected)/(endpoints)/bookmarks/[id]/logo', @@ -423,10 +426,12 @@ describe('server hooks', () => { const themedHead = await transform({ html: head, done: false }); expect(themedHead).toBe(`\t${themeStyleTag(captured.display.appearance)}\n${head}`); expect(await transform({ html: ending, done: true })).toBe(ending); + return new Response(opening + themedHead + ending, { headers: { 'content-type': 'text/html' } }); }); + const response = await hooks.handle({ event: request, resolve }); const html = await response.text(); expect(html.match(/