diff --git a/AGENTS.md b/AGENTS.md index 8002e436f..b69e5d741 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Before adding a function, search `src/lib/utils/`, `src/lib/services/`, `src/fea ## Architecture -SvelteKit 2 + Svelte 5 Runes + TypeScript | PostgreSQL + Prisma | Flowbite Svelte + Tailwind 4 | Vitest + Playwright | oxlint + ESLint +SvelteKit 3 + Svelte 5 Runes + TypeScript | PostgreSQL + Prisma | Flowbite Svelte + Tailwind 4 | Vitest + Playwright | oxlint + ESLint Layers: `prisma/` | `src/**/server/` | `src/**/zod/` | `src/**/types/`, `src/**/fixtures/` | `src/**/services/` | `src/**/utils/` | `src/**/stores/` | `src/routes/` | `src/**/*.svelte`. Each layer's constraints are in the layer table of `coding-style.md`; read it before writing logic. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2c076e81..64d742cc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ - [Node.js](https://nodejs.org): v24.x - 汎用フレームワーク - [Svelte](https://svelte.dev/): v5.x。Runes mode で運用中 - - [SvelteKit](https://svelte.dev/): v2.x + - [SvelteKit](https://svelte.dev/docs/kit): v3.x。設定は `vite.config.ts` の `sveltekit()` に書く(`svelte.config.js` は Kit 3 で[廃止](https://svelte.dev/docs/kit/migrating-to-sveltekit-3)) - UIライブラリ - [Flowbite Svelte](https://flowbite-svelte.com/) - コンポーネントライブラリ - [Flowbite](https://flowbite.com/)も利用可能 diff --git a/docs/guides/agent-rules/auth.md b/docs/guides/agent-rules/auth.md index 7bcac6d04..be1ac6c45 100644 --- a/docs/guides/agent-rules/auth.md +++ b/docs/guides/agent-rules/auth.md @@ -57,4 +57,4 @@ After login/signup, redirect users back to their original intended page instead - Never expose session secrets in client code - Use HTTPS in production - Validate all user inputs server-side -- **SvelteKit's `redirect()` does not validate origin** — always validate user-provided redirect destinations with `isSameOriginRedirect()` +- **SvelteKit 3's `redirect()` throws for an external location unless `external` is passed** — a hostile user-provided destination would turn into a 500, so always validate it with `isSameOriginRedirect()` and fall back to the default page diff --git a/docs/guides/agent-rules/sveltekit.md b/docs/guides/agent-rules/sveltekit.md index 4335aae94..f0c33f694 100644 --- a/docs/guides/agent-rules/sveltekit.md +++ b/docs/guides/agent-rules/sveltekit.md @@ -14,15 +14,16 @@ paths: ## User-Provided Redirect Destinations -**SvelteKit's `redirect()` does NOT validate origin.** If the redirect destination comes from user input (URL query params, form data), always validate with `isSameOriginRedirect(redirectTo, url.origin)` from `src/lib/utils/url.ts` before redirecting. +**SvelteKit 3's `redirect()` throws for a location outside the current origin unless `external` is passed**, so a hostile destination becomes a 500 instead of a safe fallback. If the redirect destination comes from user input (URL query params, form data), always validate with `isSameOriginRedirect(redirectTo, url.origin)` from `src/lib/utils/url.ts` and fall back to the default page. ## Internal Navigation: `resolve()` -All internal navigation must use `resolve()` from `$app/paths`: +All internal navigation must use `resolve()` from `$app/paths`. An argument starting with `/` is a route ID (groups such as `(admin)` are stripped, `[param]` is filled); otherwise it is a pathname without the leading slash. The type accepts only known route IDs and pathnames, optionally followed by `?…` or `#…` — never a plain `string`. - **Parameterized** (preferred): `resolve('/workbooks/[slug]', { slug: workbook.slug })` -- **Static**: `resolve('/')` + `@ts-expect-error` (AppTypes merging quirk) -- **With search/hash**: `resolve(pathname + search + hash)` (concatenate inside, not outside) +- **Static**: `resolve('/votes')`, `resolve('/(admin)/tags')` — write the route ID including its group; no `@ts-expect-error` +- **With search/hash**: ``resolve(`/workbooks?${params}`)`` (concatenate inside, not outside); a URL builder returns a template literal type such as `` `/workbooks?${string}` `` so `resolve()` accepts it +- **Runtime pathname** (e.g. a prop holding `/signup?redirectTo=…`): the type cannot be proven, so add `@ts-expect-error` with the reason; this works only for group-less static paths - **External**: no `resolve()`, use `rel="noreferrer external"` ## HTTP Cache Headers (`setHeaders`) diff --git a/docs/guides/agent-rules/testing-e2e.md b/docs/guides/agent-rules/testing-e2e.md index 71e826e21..8fdd67876 100644 --- a/docs/guides/agent-rules/testing-e2e.md +++ b/docs/guides/agent-rules/testing-e2e.md @@ -129,6 +129,8 @@ SvelteKit's response format for form actions differs by outcome: - `redirect()` → HTTP 200 + JSON body `{"type":"redirect","status":307,"location":"..."}` - `error(N)` → HTTP status N +- `fail(N)` → HTTP status N + JSON body `{"type":"failure","status":N,...}` (SvelteKit 3; SvelteKit 2 answered 200) +- A form whose `action` points to another page navigates there after submission, even when the action fails (SvelteKit 3) Parse the response body with a typed deserialize helper — not raw `JSON.parse`. `$app/forms` (`deserialize`) is not resolvable outside SvelteKit, so define a local `ActionResultLike` type and a `deserializeActionResult(text)` wrapper in the E2E helper (see `e2e/workbook_edit.spec.ts` for reference). Assert `type` and `location` from the deserialized result for redirect checks, and `response.status` for error checks. diff --git a/docs/guides/agent-rules/testing.md b/docs/guides/agent-rules/testing.md index 6d3e8f446..3d33eb072 100644 --- a/docs/guides/agent-rules/testing.md +++ b/docs/guides/agent-rules/testing.md @@ -127,12 +127,12 @@ For mutable module-level `const` objects (override maps), mutate directly in `be ### Browser Toggle per Describe -**Never register `vi.mock('$app/environment')` twice in one file** — the second hoisted call silently overwrites the first, pinning the whole file to one value. Use one dynamic mock with a `vi.hoisted` flag: +**Never register `vi.mock('$app/env')` twice in one file** — the second hoisted call silently overwrites the first, pinning the whole file to one value. Use one dynamic mock with a `vi.hoisted` flag: ```typescript // @vitest-environment jsdom const browserState = vi.hoisted(() => ({ value: false })); -vi.mock('$app/environment', () => ({ +vi.mock('$app/env', () => ({ get browser() { return browserState.value; }, diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 4b7f08bcd..8eec1baa1 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -75,7 +75,7 @@ feature 内は技術的な役割で分ける。必要なディレクトリだけ - `utils/`: 副作用のない関数。テストを隣接配置する。 - `stores/`: Svelte Runes を使う `.svelte.ts`。 -`features/{feature}/server/` は `src/lib/server/` と違い、SvelteKit が client import を自動では禁止しない。規約と推移的な server-only dependency により client から分離する。複数 feature で使うサーバコードは `src/lib/server/` へ移す。 +SvelteKit 3 は server-only のモジュールを client から import すると build で失敗させる。ファイル名が `server.*`・`*.server.*` のモジュールは場所を問わず server-only になる。`server/` ディレクトリ内のモジュールは、`src/routes`・`static` 内を除いて server-only になり、`features/{feature}/server/` も対象になる。複数 feature で使うサーバコードは `src/lib/server/` へ移す。 ### 3. src/lib @@ -141,17 +141,23 @@ include: [ ### 6. subpath imports(`#lib`・`#features`) -`src/lib` は `package.json#imports` の `#lib/*`、`src/features` は `#features/*` で参照する。SvelteKit 3 は組み込みの `$lib` を削除し、`kit.alias` も非推奨にするため、`svelte.config.js` に alias は置かない。 -拡張子は明示する。TS モジュールは `.js`、コンポーネントは `.svelte`、runes モジュール(`.svelte.ts`)は `.svelte.js` と書く。`vi.mock` の specifier も同じ形にする。 -`$lib` は Kit 2 が組み込みで解決してしまうため、oxlint と ESLint の `no-restricted-imports` で禁止している。lint が検出するのは import・export 宣言(`import type`・`export … from` を含む)と `.ts` の動的 `import()` で、`vi.mock('$lib/...')` と `.svelte` 内の動的 `import()` は検出しない。`$features` と `@/` は alias が無いので、import すると型チェックで失敗する。 -import の specifier をまとめて変えたときは、`rg -n '\$lib|\$features|@/' src e2e prisma` で残りが無いことも確かめる。 +- **使う:** `src/lib` は `#lib/*`、`src/features` は `#features/*`(`package.json#imports` で定義)。 +- **使わない:** `$lib`・`$features`・`@/`。SvelteKit 3 は組み込みの `$lib` を削除し、`alias` オプションも非推奨にしたため、どれも解決されず型チェックと build で失敗する。`vite.config.ts` の `sveltekit()` にも alias を足さない。 +- **拡張子は明示する:** TS モジュールは `.js`、コンポーネントは `.svelte`、runes モジュール(`.svelte.ts`)は `.svelte.js`。`vi.mock` の specifier も同じ形にする。 ```typescript +// OK import { formatDate } from '#lib/utils/format.js'; import WorkBookForm from '#features/workbooks/components/shared/WorkBookForm.svelte'; import type { PageServerLoad } from './$types'; + +// NG: SvelteKit 3 では解決されない +import { formatDate } from '$lib/utils/format'; ``` +`$lib` は、静的・動的 import、`.ts`・`.svelte`、`vi.mock` のどれで書いても型チェックか Vite(dev・build・Vitest)で失敗し、SvelteKit が `module_removed_lib` で `#lib` への書き換えを案内する。oxlint と ESLint の `no-restricted-imports` でも禁止しているので、commit 前の lint でも止まる。 +`$features`・`@/` は import なら型チェックで失敗するが、`vi.mock('$features/...')` は解決されないまま無視され、mock が効かずに本物のモジュールが使われる。specifier をまとめて変えたときは、`rg -n '\$lib|\$features|@/' src e2e prisma` で残りが無いことを確かめる。 + `$types` は SvelteKit が生成する route 型専用で、domain type は `src/**/types/` に置く。 ## 将来案・未実装 diff --git a/e2e/account_transfer.spec.ts b/e2e/account_transfer.spec.ts new file mode 100644 index 000000000..449ef384c --- /dev/null +++ b/e2e/account_transfer.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { loginAsAdmin, loginAsUser } from './helpers/auth'; + +const TIMEOUT = 60 * 1000; +const ACCOUNT_TRANSFER_URL = '/account_transfer'; +// Users that do not exist, so even a broken guard could not copy real answers +const MISSING_SOURCE_USER = 'e2e_missing_source'; +const MISSING_DESTINATION_USER = 'e2e_missing_destination'; + +// Mirrors SvelteKit's ActionResult — $app/forms is not resolvable in Playwright. +type ActionRedirect = { type: 'redirect'; status: number; location: string }; +type ActionResultLike = ActionRedirect | { type: 'success' | 'failure' | 'error' }; + +async function postAccountTransfer( + page: Page, + sourceUserName: string, + destinationUserName: string, +): Promise { + const text = await page.evaluate( + async ({ url, body }) => { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + return response.text(); + }, + { + url: ACCOUNT_TRANSFER_URL, + body: new URLSearchParams({ sourceUserName, destinationUserName }).toString(), + }, + ); + + return JSON.parse(text) as ActionResultLike; +} + +test.describe('access control', () => { + test('rejects account transfer by a non-admin user', async ({ page }) => { + await loginAsUser(page); + + await page.goto(ACCOUNT_TRANSFER_URL); + await expect(page).not.toHaveURL(new RegExp(`${ACCOUNT_TRANSFER_URL}$`), { timeout: TIMEOUT }); + + const result = await postAccountTransfer(page, MISSING_SOURCE_USER, MISSING_DESTINATION_USER); + expect(result.type).toBe('redirect'); + expect(result).toHaveProperty('location', expect.stringMatching(/\/login/)); + }); +}); + +test.describe('admin', () => { + test('shows a validation message when the account names are the same', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(ACCOUNT_TRANSFER_URL); + + await page.getByLabel('旧アカウント名').fill(MISSING_SOURCE_USER); + await page.getByLabel('新アカウント名').fill(MISSING_SOURCE_USER); + await page.getByRole('button', { name: 'コピー', exact: true }).click(); + + await expect( + page.getByText('新アカウント名は、旧アカウント名とは異なるものを指定してください'), + ).toBeVisible({ timeout: TIMEOUT }); + await expect(page.getByLabel('旧アカウント名')).toHaveValue(MISSING_SOURCE_USER); + }); +}); diff --git a/e2e/error_pages.spec.ts b/e2e/error_pages.spec.ts new file mode 100644 index 000000000..26e721472 --- /dev/null +++ b/e2e/error_pages.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +import { loginAsUser } from './helpers/auth'; + +import { FORBIDDEN, NOT_FOUND } from '../src/lib/constants/http-response-status-codes'; + +const TIMEOUT = 60 * 1000; +const ERROR_PAGE_HEADING = 'エラーが発生しました'; + +test.describe('error page', () => { + test('shows the not found error for an unknown route', async ({ page }) => { + const response = await page.goto('/e2e-route-that-does-not-exist'); + + expect(response?.status()).toBe(NOT_FOUND); + await expect(page.getByRole('heading', { name: ERROR_PAGE_HEADING })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('heading', { name: String(NOT_FOUND) })).toBeVisible(); + }); + + test('shows the forbidden error when a general user opens an admin-only page', async ({ + page, + }) => { + await loginAsUser(page); + // The create page's load throws error(403) for non-admin users + const response = await page.goto('/workbooks/create'); + + expect(response?.status()).toBe(FORBIDDEN); + await expect(page.getByRole('heading', { name: ERROR_PAGE_HEADING })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('heading', { name: String(FORBIDDEN) })).toBeVisible(); + await expect(page.getByText('アクセス権限がありません。')).toBeVisible(); + }); +}); diff --git a/e2e/problems_contest_table.spec.ts b/e2e/problems_contest_table.spec.ts index e80757b1e..cb3ced323 100644 --- a/e2e/problems_contest_table.spec.ts +++ b/e2e/problems_contest_table.spec.ts @@ -9,6 +9,8 @@ const XDPC_GROUP_TABLE_TITLES = [ '組合せゲーム 24 題', "AAL Contest 001: Let's use segtree!", ]; +const ABC319_ONWARDS_GROUP_ARIA_LABEL = 'Filter contests from ABC 319 onwards'; +const ABC319_ONWARDS_TABLE_TITLES = ['AtCoder Beginner Contest 319 〜(7 問制)']; test.describe('xDPC group in contest table', () => { test('shows Game 24 and AAL Contest 001 tables after FPS 24 in the xDPC group', async ({ @@ -22,3 +24,18 @@ test.describe('xDPC group in contest table', () => { await expect(page.getByRole('heading', { level: 2 })).toHaveText(XDPC_GROUP_TABLE_TITLES); }); }); + +test.describe('contest type selection', () => { + // ABC 319 onwards is the group most users open + test('keeps the selected contest type after reload', async ({ page }) => { + await page.goto('/problems'); + + await page.getByRole('button', { name: ABC319_ONWARDS_GROUP_ARIA_LABEL }).click(); + await expect(page.getByRole('heading', { level: 2 })).toHaveText(ABC319_ONWARDS_TABLE_TITLES); + + await page.reload(); + + // The default group is ABS, so these headings only appear when the choice was saved + await expect(page.getByRole('heading', { level: 2 })).toHaveText(ABC319_ONWARDS_TABLE_TITLES); + }); +}); diff --git a/e2e/problems_tabs.spec.ts b/e2e/problems_tabs.spec.ts new file mode 100644 index 000000000..3a87ea1d3 --- /dev/null +++ b/e2e/problems_tabs.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +const PROBLEMS_URL = '/problems'; +const GRADE_TAB_TITLE = 'グレード別'; +const CONTEST_TABLE_TAB_TITLE = 'コンテスト別(アルファ版)'; + +test.describe('problem list tabs', () => { + test('keeps the selected problem list tab after reload', async ({ page }) => { + await page.goto(PROBLEMS_URL); + + await page.getByRole('tab', { name: GRADE_TAB_TITLE }).click(); + await expect(page.getByRole('tab', { name: GRADE_TAB_TITLE })).toHaveAttribute( + 'aria-selected', + 'true', + ); + + await page.reload(); + + await expect(page.getByRole('tab', { name: GRADE_TAB_TITLE })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.getByRole('tab', { name: CONTEST_TABLE_TAB_TITLE })).toHaveAttribute( + 'aria-selected', + 'false', + ); + }); +}); diff --git a/e2e/signin.spec.ts b/e2e/signin.spec.ts index b5c928489..4d51b2e16 100644 --- a/e2e/signin.spec.ts +++ b/e2e/signin.spec.ts @@ -1,11 +1,24 @@ import { test, expect, Page } from '@playwright/test'; import { PRODUCT_CATCH_PHRASE } from '../src/lib/constants/product-info.ts'; +import { BAD_REQUEST } from '../src/lib/constants/http-response-status-codes.ts'; // Set timeout limit const UP_TO_ONE_MINUTE = 60 * 1000; // 60 sec * 1000 ms // Sample username and password for testing const USERNAME = 'guest'; const PASSWORD = 'Ch0kuda1'; +const WRONG_PASSWORD = 'Wr0ngPassword'; +const LOGIN_FAILURE_MESSAGE = + 'ログインできませんでした。登録したユーザ名 / パスワードとなるように修正してください。'; +const SESSION_COOKIE_NAME = 'auth_session'; + +// Mirrors SvelteKit's ActionResult — $app/forms is not resolvable in Playwright. +type ActionFailure = { type: 'failure'; status: number }; +type ActionResultLike = ActionFailure | { type: 'success' | 'redirect' | 'error' }; + +function deserializeActionResult(text: string): ActionResultLike { + return JSON.parse(text) as ActionResultLike; +} // TODO: Make available in global.setup.ts async function login(page: Page, username: string, password: string): Promise { @@ -56,3 +69,67 @@ test('logout', async ({ page }) => { await login(page, USERNAME, PASSWORD); await logout(page, USERNAME); }); + +test('redirects to home when logging out without a session', async ({ page }) => { + await login(page, USERNAME, PASSWORD); + await page.goto('/workbooks'); + await expect(page).toHaveURL(/\/workbooks/, { timeout: UP_TO_ONE_MINUTE }); + // Without a session the logout action fails; the form posts to another page, so SvelteKit 3 navigates there + await page.context().clearCookies(); + + await logout(page, USERNAME); +}); + +test('keeps the session cookie attributes after login', async ({ page }) => { + await login(page, USERNAME, PASSWORD); + + const sessionCookie = (await page.context().cookies()).find( + (cookie) => cookie.name === SESSION_COOKIE_NAME, + ); + expect(sessionCookie).toMatchObject({ path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }); + + // The session must survive a navigation to a page that requires it + await page.goto('/users/edit'); + await expect(page).toHaveURL('/users/edit', { timeout: UP_TO_ONE_MINUTE }); +}); + +test.describe('login failure', () => { + test('keeps form values after a failed enhanced submission', async ({ page }) => { + await page.goto('/login'); + await page.locator('input[name="username"]').fill(USERNAME); + await page.locator('input[name="password"]').fill(WRONG_PASSWORD); + await page.getByRole('button', { name: 'ログイン', exact: true }).click(); + + await expect(page.getByText(LOGIN_FAILURE_MESSAGE)).toBeVisible({ timeout: UP_TO_ONE_MINUTE }); + await expect(page).toHaveURL('/login'); + await expect(page.locator('input[name="username"]')).toHaveValue(USERNAME); + }); + + test('returns the fail status for an enhanced submission', async ({ page }) => { + await page.goto('/login'); + + const result = await page.evaluate( + async ({ username, password }) => { + const body = new FormData(); + body.set('username', username); + body.set('password', password); + // Same headers as use:enhance, so SvelteKit answers with an ActionResult + const response = await fetch('/login', { + method: 'POST', + headers: { accept: 'application/json', 'x-sveltekit-action': 'true' }, + body, + }); + + return { status: response.status, body: await response.text() }; + }, + { username: USERNAME, password: WRONG_PASSWORD }, + ); + + // SvelteKit 2 answered 200 here; SvelteKit 3 uses the status passed to fail() + expect(result.status).toBe(BAD_REQUEST); + expect(deserializeActionResult(result.body)).toMatchObject({ + type: 'failure', + status: BAD_REQUEST, + }); + }); +}); diff --git a/e2e/submission_status.spec.ts b/e2e/submission_status.spec.ts new file mode 100644 index 000000000..a71be1bce --- /dev/null +++ b/e2e/submission_status.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; + +import { loginAsUser } from './helpers/auth'; + +const PROBLEMS_URL = '/problems'; +// The only ABS task in the seed, so it is the only cell in the default contest table group +const TASK_TITLE = 'Welcome to AtCoder'; +const STATUS_LABELS = ['AC', '解説AC', '挑戦中', '未挑戦'] as const; + +type StatusLabel = (typeof STATUS_LABELS)[number]; + +function statusItem(page: Page, label: StatusLabel): Locator { + return page.getByRole('button', { name: label, exact: true }); +} + +async function openStatusDropdown(page: Page): Promise { + const taskCell = page + .getByRole('cell') + .filter({ has: page.getByRole('link', { name: TASK_TITLE }) }); + + await taskCell.getByRole('button', { name: 'Update submission status' }).click(); + await expect(statusItem(page, 'AC')).toBeVisible(); +} + +// The cell shows the status only as a background color, so read the check icon in the dropdown +async function readCheckedStatus(page: Page): Promise { + await openStatusDropdown(page); + + for (const label of STATUS_LABELS) { + if ((await statusItem(page, label).locator('svg').count()) > 0) { + return label; + } + } + + throw new Error('No submission status is checked'); +} + +async function selectStatus(page: Page, label: StatusLabel): Promise { + const saved = page.waitForResponse( + (response) => response.url().includes('?/update') && response.request().method() === 'POST', + ); + + await statusItem(page, label).click(); + expect((await saved).ok()).toBe(true); +} + +// Reloads first so the result reflects the server, not the optimistic update in the page +async function setStatusAfterReload(page: Page, label: StatusLabel): Promise { + await page.reload(); + + if ((await readCheckedStatus(page)) !== label) { + await selectStatus(page, label); + } +} + +test.describe('submission status in the contest table', () => { + test('keeps the updated status after reload', async ({ page }) => { + await loginAsUser(page); + await page.goto(PROBLEMS_URL); + + const originalStatus = await readCheckedStatus(page); + const updatedStatus: StatusLabel = originalStatus === 'AC' ? '挑戦中' : 'AC'; + + try { + await selectStatus(page, updatedStatus); + await page.reload(); + + // The dropdown updates the page without checking the response, so only a reload proves it was saved + expect(await readCheckedStatus(page)).toBe(updatedStatus); + } finally { + await setStatusAfterReload(page, originalStatus); + } + }); +}); diff --git a/e2e/workbook_edit.spec.ts b/e2e/workbook_edit.spec.ts index 8fd401f04..542a2e2d5 100644 --- a/e2e/workbook_edit.spec.ts +++ b/e2e/workbook_edit.spec.ts @@ -1,6 +1,6 @@ import { test, expect, type Page } from '@playwright/test'; -import { loginAsUser } from './helpers/auth'; +import { loginAsAdmin, loginAsUser } from './helpers/auth'; import { FORBIDDEN } from '../src/lib/constants/http-response-status-codes'; @@ -56,3 +56,34 @@ test.describe('workbook edit page access control (load)', () => { await expect(page).toHaveURL(/\/login/, { timeout: TIMEOUT }); }); }); + +test.describe('workbook edit form', () => { + test('keeps edited nested workbook tasks after a failed submission', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(WORKBOOK_EDIT_URL); + + const taskRows = page.getByRole('table', { name: 'Workbook tasks' }).locator('tbody tr'); + await expect(taskRows.first()).toBeVisible({ timeout: TIMEOUT }); + const taskRowTexts = await taskRows.allTextContents(); + + // Removing the last row leaves the other row numbers unchanged + const lastRowNumber = String(taskRowTexts.length); + const editedRowTexts = taskRowTexts.slice(0, -1); + page.once('dialog', (dialog) => dialog.accept()); + await taskRows + .filter({ has: page.getByRole('cell', { name: lastRowNumber, exact: true }) }) + .getByRole('button', { name: '削除' }) + .click(); + await expect(taskRows).toHaveText(editedRowTexts); + + // A two-character title fails server validation, so nothing is saved + await page.locator('input[name="title"]').fill('ab'); + await page.getByRole('button', { name: '更新', exact: true }).click(); + + await expect(page.getByText('3文字以上入力してください')).toBeVisible({ timeout: TIMEOUT }); + await expect(page).toHaveURL(WORKBOOK_EDIT_URL); + await expect(page.locator('input[name="title"]')).toHaveValue('ab'); + // The table must show the submitted tasks, not the workbook's original tasks + await expect(taskRows).toHaveText(editedRowTexts); + }); +}); diff --git a/e2e/workbook_order.spec.ts b/e2e/workbook_order.spec.ts index 34751385a..cf2891252 100644 --- a/e2e/workbook_order.spec.ts +++ b/e2e/workbook_order.spec.ts @@ -215,6 +215,35 @@ test.describe('workbook order page', () => { expect(categories.split(',')).not.toContain(SolutionCategory.DATA_STRUCTURE); expect(categories.split(',')).toContain(SolutionCategory.GRAPH); }); + + test('replaces filter URL without reloading data or adding history', async ({ page }) => { + await page.goto( + `${ORDER_URL}?tab=solution&categories=${SolutionCategory.PENDING},${SolutionCategory.GRAPH},${SolutionCategory.DATA_STRUCTURE}`, + ); + await expect(page.getByRole('heading', { name: 'データ構造' })).toBeVisible({ + timeout: TIMEOUT, + }); + + const historyLength = await page.evaluate(() => history.length); + const graphCards = await getCardsInColumn(page, SolutionCategory.GRAPH); + // Unsaved drag results live only in the board's local state, so a data reload would discard them + const dataRequests: string[] = []; + + page.on('request', (request) => { + if (request.url().includes('__data.json')) { + dataRequests.push(request.url()); + } + }); + + await page.getByRole('button', { name: 'データ構造' }).click(); + await expect(page.getByRole('heading', { name: 'データ構造' })).not.toBeVisible(); + await expect(page).not.toHaveURL(new RegExp(SolutionCategory.DATA_STRUCTURE)); + await expect(page).toHaveURL(new RegExp(`categories=[^&]*${SolutionCategory.GRAPH}`)); + + expect(await page.evaluate(() => history.length)).toBe(historyLength); + expect(dataRequests).toEqual([]); + expect(await getCardsInColumn(page, SolutionCategory.GRAPH)).toEqual(graphCards); + }); }); test.describe('API error handling', () => { diff --git a/e2e/workbooks_list.spec.ts b/e2e/workbooks_list.spec.ts index 6a773e7eb..6fdd50a63 100644 --- a/e2e/workbooks_list.spec.ts +++ b/e2e/workbooks_list.spec.ts @@ -225,6 +225,27 @@ test.describe('logged-in user (general)', () => { timeout: TIMEOUT, }); }); + + test('restores saved workbook filters without adding a history entry', async ({ page }) => { + await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_SOLUTION}&categories=${CATEGORY_GRAPH}`); + await expect(page.getByRole('tab', { name: '解法別' })).toHaveAttribute( + 'aria-selected', + 'true', + { timeout: TIMEOUT }, + ); + + await page.goto('/'); + await expect(page).toHaveURL('/', { timeout: TIMEOUT }); + + await page.getByRole('link', { name: '問題集', exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`categories=${CATEGORY_GRAPH}`), { + timeout: TIMEOUT, + }); + + // The restore replaces the bare /workbooks entry, so going back returns to the previous page + await page.goBack(); + await expect(page).toHaveURL('/', { timeout: TIMEOUT }); + }); }); test('toggling replenishment workbooks shows/hides the section when it exists', async ({ diff --git a/eslint.config.mjs b/eslint.config.mjs index cd19da504..0a6656ee4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -80,7 +80,7 @@ export default [ 'svelte/no-useless-mustaches': 'warn', 'svelte/prefer-writable-derived': 'warn', // New in 3.6.0 - prefer $derived over $state+$effect 'svelte/valid-prop-names-in-kit-pages': 'warn', // Allow props other than data/errors in pages - // Kit 2 still resolves $lib, so new imports would only break on Kit 3; oxlint covers JS/TS files + // Stops $lib at pre-commit, before svelte-check or Vite reports it; oxlint covers JS/TS files 'no-restricted-imports': [ 'error', { diff --git a/package.json b/package.json index 4ee8d36dd..c0e8b8141 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "@eslint/eslintrc": "3.3.7", "@playwright/test": "1.64.1", "@quramy/prisma-fabbrica": "2.3.3", - "@sveltejs/adapter-vercel": "6.3.4", - "@sveltejs/kit": "2.70.3", + "@sveltejs/adapter-vercel": "7.0.1", + "@sveltejs/kit": "3.1.0", "@sveltejs/vite-plugin-svelte": "7.3.1", "@tailwindcss/forms": "0.5.11", "@tailwindcss/vite": "4.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c7c2d7ff..a022ceb6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,11 +235,11 @@ importers: specifier: 2.3.3 version: 2.3.3(@prisma/client@5.22.0(prisma@5.22.0))(typescript@6.0.3) '@sveltejs/adapter-vercel': - specifier: 6.3.4 - version: 6.3.4(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(supports-color@7.2.0) + specifier: 7.0.1 + version: 7.0.1(@sveltejs/kit@3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(supports-color@7.2.0) '@sveltejs/kit': - specifier: 2.70.3 - version: 2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)) + specifier: 3.1.0 + version: 3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)) '@sveltejs/vite-plugin-svelte': specifier: 7.3.1 version: 7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)) @@ -329,7 +329,7 @@ importers: version: 5.0.3(svelte@5.57.2(@typescript-eslint/types@8.71.2)) sveltekit-superforms: specifier: 3.0.0 - version: 3.0.0(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(@types/json-schema@7.0.15)(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3) + version: 3.0.0(@sveltejs/kit@3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(@types/json-schema@7.0.15)(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3) tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -1826,32 +1826,26 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@sveltejs/acorn-typescript@1.0.10': - resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} - peerDependencies: - acorn: ^8.9.0 - '@sveltejs/acorn-typescript@1.0.13': resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} peerDependencies: acorn: ^8.9.0 - '@sveltejs/adapter-vercel@6.3.4': - resolution: {integrity: sha512-MxEgTqIlmNg4qy5Rz4vwDKs2vNUrsL+DjcfegUVr/spPWSvWrSYFTUPY7tLbHsi8RxdUqpxC9UN/yzkOzZeMGA==} - engines: {node: '>=20.0'} + '@sveltejs/adapter-vercel@7.0.1': + resolution: {integrity: sha512-BnIGkQACaEeiXYcC5idodcUylp9Jyyx9aHIb7sqcGWfj8X3nJ57y8abmhFuAkg3oipLhaEfRqwwNtbuVhEaLhQ==} peerDependencies: - '@sveltejs/kit': ^2.4.0 + '@sveltejs/kit': ^3.0.0-next.0 - '@sveltejs/kit@2.70.3': - resolution: {integrity: sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==} - engines: {node: '>=18.13'} + '@sveltejs/kit@3.1.0': + resolution: {integrity: sha512-JTWlq2HQlkqx6vIoxhC0R6sc4WVqbQoRyEmr5YlqKmiYbzY1U3awtSWTmjMLlS056a49AmkQR6rnNANXIE8B0Q==} + engines: {node: '>=22.17'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.0.0 - '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 - svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: ^5.3.3 || ^6.0.0 - vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + '@sveltejs/vite-plugin-svelte': ^7.0.0 + svelte: ^5.57.1 + typescript: ^6.0.0 + vite: ^8.0.12 peerDependenciesMeta: '@opentelemetry/api': optional: true @@ -1981,9 +1975,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2575,9 +2566,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -2655,9 +2646,6 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.8.1: - resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} - devalue@5.9.4: resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} @@ -3142,10 +3130,6 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} @@ -3668,10 +3652,6 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -3940,9 +3920,6 @@ packages: engines: {node: '>=10'} hasBin: true - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} - setprototypeof@1.1.1: resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==} @@ -5526,38 +5503,29 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@sveltejs/acorn-typescript@1.0.10(acorn@8.17.0)': - dependencies: - acorn: 8.17.0 - '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': dependencies: acorn: 8.18.0 - '@sveltejs/adapter-vercel@6.3.4(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(supports-color@7.2.0)': + '@sveltejs/adapter-vercel@7.0.1(@sveltejs/kit@3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(supports-color@7.2.0)': dependencies: - '@sveltejs/kit': 2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)) + '@sveltejs/kit': 3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)) '@vercel/nft': 1.10.2(supports-color@7.2.0) - esbuild: 0.28.1 transitivePeerDependencies: - encoding - rollup - supports-color - '@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15))': + '@sveltejs/kit@3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15))': dependencies: + '@rolldown/pluginutils': 1.0.1 '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.10(acorn@8.17.0) '@sveltejs/vite-plugin-svelte': 7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)) - '@types/cookie': 0.6.0 - acorn: 8.17.0 - cookie: 0.7.2 - devalue: 5.8.1 + cookie: 2.0.1 + devalue: 5.9.4 esm-env: 1.2.2 - kleur: 4.1.5 - magic-string: 0.30.21 + magic-string: 1.4.3 mrmime: 2.0.1 - set-cookie-parser: 3.1.0 sirv: 3.0.2 svelte: 5.57.2(@typescript-eslint/types@8.71.2) vite: 8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15) @@ -5667,8 +5635,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/cookie@0.6.0': {} - '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -6071,15 +6037,15 @@ snapshots: dependencies: '@mapbox/node-pre-gyp': 2.0.3(supports-color@7.2.0) '@rollup/pluginutils': 5.4.0 - acorn: 8.17.0 - acorn-import-attributes: 1.9.5(acorn@8.17.0) + acorn: 8.18.0 + acorn-import-attributes: 1.9.5(acorn@8.18.0) async-sema: 3.1.1 bindings: 1.5.0 estree-walker: 2.0.2 glob: 13.0.6 graceful-fs: 4.2.11 node-gyp-build: 4.8.4 - picomatch: 4.0.4 + picomatch: 4.0.7 resolve-from: 5.0.0 transitivePeerDependencies: - encoding @@ -6316,10 +6282,6 @@ snapshots: abbrev@3.0.1: {} - acorn-import-attributes@1.9.5(acorn@8.17.0): - dependencies: - acorn: 8.17.0 - acorn-import-attributes@1.9.5(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -6502,7 +6464,7 @@ snapshots: convert-source-map@2.0.0: {} - cookie@0.7.2: {} + cookie@2.0.1: {} cross-spawn@7.0.6: dependencies: @@ -6561,8 +6523,6 @@ snapshots: detect-libc@2.1.2: {} - devalue@5.8.1: {} - devalue@5.9.4: {} dlv@1.1.3: @@ -7155,8 +7115,6 @@ snapshots: kleur@3.0.3: {} - kleur@4.1.5: {} - known-css-properties@0.37.0: {} lefthook-android-arm64@2.2.2: @@ -7610,8 +7568,6 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} - picomatch@4.0.5: {} picomatch@4.0.7: {} @@ -7852,8 +7808,6 @@ snapshots: semver@7.8.5: {} - set-cookie-parser@3.1.0: {} - setprototypeof@1.1.1: {} shebang-command@2.0.0: @@ -7970,9 +7924,9 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' - sveltekit-superforms@3.0.0(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(@types/json-schema@7.0.15)(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3): + sveltekit-superforms@3.0.0(@sveltejs/kit@3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(@types/json-schema@7.0.15)(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3): dependencies: - '@sveltejs/kit': 2.70.3(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)) + '@sveltejs/kit': 3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.2))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)))(svelte@5.57.2(@typescript-eslint/types@8.71.2))(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15)) devalue: 5.9.4 esm-env: 1.2.2 memoize-weak: 1.0.2 diff --git a/src/app.d.ts b/src/app.d.ts index 30b104fb9..e459f6e35 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,4 +1,4 @@ -// See https://kit.svelte.dev/docs/types#app +// See https://svelte.dev/docs/kit/types#app import type { Roles } from '@prisma/client'; diff --git a/src/features/auth/server/auth.test.ts b/src/features/auth/server/auth.test.ts index dfaa4fefb..ee759bdd8 100644 --- a/src/features/auth/server/auth.test.ts +++ b/src/features/auth/server/auth.test.ts @@ -4,7 +4,7 @@ import type { RequestEvent } from '@sveltejs/kit'; import { Roles } from '@prisma/client'; // dev = false so `secure: !dev` resolves to true (the production-relevant cookie flag) -vi.mock('$app/environment', () => ({ dev: false })); +vi.mock('$app/env', () => ({ dev: false })); vi.mock('./session', () => ({ SESSION_COOKIE_NAME: 'auth_session', diff --git a/src/features/auth/server/auth.ts b/src/features/auth/server/auth.ts index b2e84047b..f768e32a6 100644 --- a/src/features/auth/server/auth.ts +++ b/src/features/auth/server/auth.ts @@ -1,4 +1,4 @@ -import { dev } from '$app/environment'; +import { dev } from '$app/env'; import type { RequestEvent } from '@sveltejs/kit'; import { diff --git a/src/features/tasks/stores/active_contest_type.svelte.test.ts b/src/features/tasks/stores/active_contest_type.svelte.test.ts index c6b1c998d..139f43200 100644 --- a/src/features/tasks/stores/active_contest_type.svelte.test.ts +++ b/src/features/tasks/stores/active_contest_type.svelte.test.ts @@ -9,12 +9,12 @@ import { } from '#features/tasks/stores/active_contest_type.svelte.js'; // WHY: a single dynamic mock is the only way to toggle `browser` per describe. Every vi.mock is -// hoisted and the last registration for a module wins, so a second vi.mock of `$app/environment` +// hoisted and the last registration for a module wins, so a second vi.mock of `$app/env` // would silently clamp the whole file to one branch. // Defaults to false so the singleton constructed at import time stays SSR-safe (no localStorage). const browserState = vi.hoisted(() => ({ value: false })); -vi.mock('$app/environment', () => ({ +vi.mock('$app/env', () => ({ get browser() { return browserState.value; }, diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index f92442054..d721ae804 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -62,7 +62,6 @@ // Use task_id as a deterministic component ID to avoid SSR/hydration mismatches. const componentId = taskResult.task_id; - // @ts-expect-error svelte-check TS2554: AppTypes declaration merging causes RouteId to resolve as string, requiring params. Runtime behavior is correct. const editProfileHref = `${resolve(EDIT_PROFILE_PAGE)}?tab=atcoder`; let selectedVoteGrade = $state(); diff --git a/src/features/workbooks/stores/replenishment_workbook.svelte.ts b/src/features/workbooks/stores/replenishment_workbook.svelte.ts index 6eb7fa62c..4338224e2 100644 --- a/src/features/workbooks/stores/replenishment_workbook.svelte.ts +++ b/src/features/workbooks/stores/replenishment_workbook.svelte.ts @@ -1,6 +1,6 @@ // See: -// https://svelte.dev/docs/kit/$app-environment#browser -import { browser } from '$app/environment'; +// https://svelte.dev/docs/kit/$app-env#browser +import { browser } from '$app/env'; // See: // https://svelte.dev/docs/svelte/stores diff --git a/src/features/workbooks/stores/replenishment_workbook.test.ts b/src/features/workbooks/stores/replenishment_workbook.test.ts index 07ba8c782..1758fda11 100644 --- a/src/features/workbooks/stores/replenishment_workbook.test.ts +++ b/src/features/workbooks/stores/replenishment_workbook.test.ts @@ -8,12 +8,12 @@ import { } from '#features/workbooks/stores/replenishment_workbook.svelte.js'; // WHY: a single dynamic mock is the only way to toggle `browser` per describe. Every vi.mock is -// hoisted and the last registration for a module wins, so a second vi.mock of `$app/environment` +// hoisted and the last registration for a module wins, so a second vi.mock of `$app/env` // would silently clamp the whole file to one branch. // Defaults to false so the singleton constructed at import time stays SSR-safe (no localStorage). const browserState = vi.hoisted(() => ({ value: false })); -vi.mock('$app/environment', () => ({ +vi.mock('$app/env', () => ({ get browser() { return browserState.value; }, diff --git a/src/features/workbooks/utils/workbook_url_params.ts b/src/features/workbooks/utils/workbook_url_params.ts index 7a04ddcf1..90f183930 100644 --- a/src/features/workbooks/utils/workbook_url_params.ts +++ b/src/features/workbooks/utils/workbook_url_params.ts @@ -60,13 +60,13 @@ export function parseWorkBookCategory(params: URLSearchParams): SelectedSolution * @param tab - Active tab * @param grade - Selected grade (only appended when tab === CURRICULUM) * @param category - Selected category (only appended when tab === SOLUTION) - * @returns URL string suitable for use with goto() + * @returns URL string suitable for use with goto(); typed as a route ID with search so `resolve()` accepts it */ export function buildWorkbooksUrl( tab: WorkBookTab, grade?: TaskGrade, category?: SelectedSolutionCategory, -): string { +): `/workbooks?${string}` { const params = new URLSearchParams(); params.set('tab', tab); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 03e158386..c73efc0fc 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,7 +1,7 @@ // See: // https://github.com/joysofcode/sveltekit-deploy // https://tech-blog.rakus.co.jp/entry/20230209/sveltekit -import type { Handle } from '@sveltejs/kit'; +import type { Handle } from '@sveltejs/kit/hooks'; import { createAuthRequest } from '#features/auth/server/auth.js'; diff --git a/src/lib/actions/handle_dropdown.ts b/src/lib/actions/handle_dropdown.ts index 0405bea88..cf6889a64 100644 --- a/src/lib/actions/handle_dropdown.ts +++ b/src/lib/actions/handle_dropdown.ts @@ -1,5 +1,5 @@ import { writable, type Writable } from 'svelte/store'; -import { browser } from '$app/environment'; +import { browser } from '$app/env'; const activeDropdownId: Writable = writable(null); diff --git a/src/lib/components/AuthForm.svelte b/src/lib/components/AuthForm.svelte index 2e280efe8..977f5c363 100644 --- a/src/lib/components/AuthForm.svelte +++ b/src/lib/components/AuthForm.svelte @@ -42,11 +42,9 @@ const { form, message, errors, submitting, enhance } = formProperties; - // @ts-expect-error svelte-check TS2554: AppTypes declaration merging causes RouteId to resolve as string, requiring params. Runtime behavior is correct. const homeHref = resolve(HOME_PAGE); - // @ts-expect-error svelte-check TS2554: same declaration merging issue const forgotPasswordHref = resolve(FORGOT_PASSWORD_PAGE); - // @ts-expect-error svelte-check TS2554: AppTypes declaration merging causes RouteId to resolve as string, requiring params. Runtime behavior is correct. + // @ts-expect-error alternativePageLink is a runtime pathname such as '/signup?redirectTo=...'; Kit 3 types only known route IDs, but resolve() returns group-less static paths as is const alternativeHref = $derived(resolve(alternativePageLink)); let isSubmitting = $state(false); diff --git a/src/lib/components/TagForm.svelte b/src/lib/components/TagForm.svelte index 582111585..88b4951d3 100644 --- a/src/lib/components/TagForm.svelte +++ b/src/lib/components/TagForm.svelte @@ -29,7 +29,6 @@ //export const isAdmin: boolean; // Admin権限がある場合は、編集リンクを表示する - // @ts-expect-error svelte-check TS2554: AppTypes declaration merging causes RouteId to resolve as string, requiring params. Runtime behavior is correct. const tagsHref = resolve('/(admin)/tags'); diff --git a/src/lib/stores/local_storage_helper.svelte.ts b/src/lib/stores/local_storage_helper.svelte.ts index b730691b6..8c93775e4 100644 --- a/src/lib/stores/local_storage_helper.svelte.ts +++ b/src/lib/stores/local_storage_helper.svelte.ts @@ -1,6 +1,6 @@ // See: -// https://svelte.dev/docs/kit/$app-environment#browser -import { browser } from '$app/environment'; +// https://svelte.dev/docs/kit/$app-env#browser +import { browser } from '$app/env'; export function useLocalStorage(key: string, initialValue: T) { return new LocalStorageWrapper(key, initialValue); diff --git a/src/routes/(admin)/tasks/+page.svelte b/src/routes/(admin)/tasks/+page.svelte index b5f620711..5e1801813 100644 --- a/src/routes/(admin)/tasks/+page.svelte +++ b/src/routes/(admin)/tasks/+page.svelte @@ -1,6 +1,5 @@ diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 288728c8d..0f1b65482 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -27,7 +27,6 @@ const redirectTo = page.url.pathname; - // @ts-expect-error svelte-check TS2554: AppTypes declaration merging causes RouteId to resolve as string, requiring params. Runtime behavior is correct. const editProfileHref = `${resolve(EDIT_PROFILE_PAGE)}?tab=atcoder`; const totalVotes = $derived( @@ -42,7 +41,7 @@
diff --git a/src/routes/workbooks/+page.svelte b/src/routes/workbooks/+page.svelte index 0d4bd83a9..2a29c8ffa 100644 --- a/src/routes/workbooks/+page.svelte +++ b/src/routes/workbooks/+page.svelte @@ -50,8 +50,8 @@ if (saved) { const savedUrl = new URL(saved, window.location.origin); - // @ts-expect-error svelte-check TS2554: AppTypes declaration merging causes RouteId to resolve as string, requiring params. Runtime behavior is correct. - goto(resolve(savedUrl.pathname + savedUrl.search), { replaceState: true }); + // The saved value always comes from buildWorkbooksUrl, so only its query is restored + goto(resolve(`/workbooks?${savedUrl.searchParams}`), { replace: true }); } }); @@ -61,7 +61,7 @@ }); // Each lambda closes over the reactive `data` binding at call time - const TAB_URL_BUILDERS: Record string> = { + const TAB_URL_BUILDERS: Record ReturnType> = { [WorkBookTab.CURRICULUM]: () => buildWorkbooksUrl(WorkBookTab.CURRICULUM, data.selectedGrade), [WorkBookTab.SOLUTION]: () => buildWorkbooksUrl(WorkBookTab.SOLUTION, undefined, data.selectedCategory), @@ -69,17 +69,14 @@ }; function handleTabChange(tab: WorkBookTab) { - // @ts-expect-error svelte-check TS2554: same declaration merging issue goto(resolve(TAB_URL_BUILDERS[tab]())); } function handleGradeChange(grade: TaskGrade) { - // @ts-expect-error svelte-check TS2554: same declaration merging issue goto(resolve(buildWorkbooksUrl(WorkBookTab.CURRICULUM, grade))); } function handleCategoryChange(category: SelectedSolutionCategory) { - // @ts-expect-error svelte-check TS2554: same declaration merging issue goto(resolve(buildWorkbooksUrl(WorkBookTab.SOLUTION, undefined, category))); } diff --git a/src/test/lib/stores/active_problem_list_tab.svelte.test.ts b/src/test/lib/stores/active_problem_list_tab.svelte.test.ts index 99f0da1c7..204790be3 100644 --- a/src/test/lib/stores/active_problem_list_tab.svelte.test.ts +++ b/src/test/lib/stores/active_problem_list_tab.svelte.test.ts @@ -8,12 +8,12 @@ import { } from '#lib/stores/active_problem_list_tab.svelte.js'; // WHY: a single dynamic mock is the only way to toggle `browser` per describe. Every vi.mock is -// hoisted and the last registration for a module wins, so a second vi.mock of `$app/environment` +// hoisted and the last registration for a module wins, so a second vi.mock of `$app/env` // would silently clamp the whole file to one branch. // Defaults to false so the singleton constructed at import time stays SSR-safe (no localStorage). const browserState = vi.hoisted(() => ({ value: false })); -vi.mock('$app/environment', () => ({ +vi.mock('$app/env', () => ({ get browser() { return browserState.value; }, diff --git a/svelte.config.js b/svelte.config.js deleted file mode 100644 index 322d27072..000000000 --- a/svelte.config.js +++ /dev/null @@ -1,39 +0,0 @@ -import adapter from '@sveltejs/adapter-vercel'; -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - // Consult https://kit.svelte.dev/docs/integrations#preprocessors - // for more information about preprocessors - preprocess: vitePreprocess(), - - kit: { - // See: - // https://svelte.dev/docs/kit/adapter-vercel - adapter: adapter({ - regions: ['hnd1'], // Tokyo, Japan. - memory: 3008, // To avoid OOM errors on /workbooks/{slug} - maxDuration: 30, - }), - // Module paths use package.json#imports (#lib, #features); SvelteKit 3 deprecates kit.alias - }, - - compilerOptions: { - runes: true, - }, - - // [Work around] - // SuperDebug.svelte: Cannot use `export let` in runes mode — use `$props()` instead. - // - // See: - // https://github.com/ciscoheat/sveltekit-superforms/issues/306#issuecomment-1891015986 - vitePlugin: { - dynamicCompileOptions({ filename }) { - if (filename.includes('node_modules')) { - return { runes: undefined }; // or false, check what works - } - }, - }, -}; - -export default config; diff --git a/tsconfig.json b/tsconfig.json index 91c9443c9..294a1483e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,11 @@ { - "extends": "./.svelte-kit/tsconfig.json", + "extends": "$app/tsconfig", "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, "incremental": true, - "isolatedModules": true, - "moduleResolution": "bundler", "outDir": "build", - "resolveJsonModule": true, - "skipLibCheck": true, "sourceMap": true, "strict": true, - "types": ["node", "vitest/globals"], // Note: "node" is required because "types" is replaced (not merged) when extending tsconfig - "verbatimModuleSyntax": true + "types": ["$app/types", "node", "vitest/globals"] // Note: "types" is replaced (not merged) when extending tsconfig, so keep "$app/types" and add "node" }, // Module paths are resolved by package.json#imports (#lib, #features), not path aliases // diff --git a/vite.config.ts b/vite.config.ts index 0d77361b3..992ce6b26 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,42 @@ +import adapter from '@sveltejs/adapter-vercel'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [tailwindcss(), sveltekit()], + plugins: [ + tailwindcss(), + // SvelteKit 3 takes the former svelte.config.js options here + sveltekit({ + // Consult https://svelte.dev/docs/kit/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + // See: + // https://svelte.dev/docs/kit/adapter-vercel + adapter: adapter({ + regions: ['hnd1'], // Tokyo, Japan. + memory: 3008, // To avoid OOM errors on /workbooks/{slug} + maxDuration: 30, + }), + + compilerOptions: { + runes: true, + }, + + // [Work around] + // SuperDebug.svelte: Cannot use `export let` in runes mode — use `$props()` instead. + // + // See: + // https://github.com/ciscoheat/sveltekit-superforms/issues/306#issuecomment-1891015986 + dynamicCompileOptions({ filename }) { + if (filename.includes('node_modules')) { + return { runes: undefined }; // or false, check what works + } + }, + }), + ], optimizeDeps: { exclude: ['@prisma/client'], },