Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)も利用可能
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/agent-rules/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 5 additions & 4 deletions docs/guides/agent-rules/sveltekit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/agent-rules/testing-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/guides/agent-rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down
16 changes: 11 additions & 5 deletions docs/guides/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/` に置く。

## 将来案・未実装
Expand Down
66 changes: 66 additions & 0 deletions e2e/account_transfer.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ActionResultLike> {
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);
});
});
35 changes: 35 additions & 0 deletions e2e/error_pages.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 17 additions & 0 deletions e2e/problems_contest_table.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand All @@ -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);
});
});
28 changes: 28 additions & 0 deletions e2e/problems_tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
77 changes: 77 additions & 0 deletions e2e/signin.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
Expand Down Expand Up @@ -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,
});
});
});
Loading
Loading