refactor css & tests
Mbun.lock
| @@ -8,14 +8,12 @@ | |||
|---|---|---|---|
| 8 | 8 | "@elysiajs/static": "^1.4.7", | |
| 9 | 9 | "@jsquash/jxl": "^1.3.0", | |
| 10 | 10 | "@kitajs/html": "^4.2.13", | |
| 11 | - | "@simplewebauthn/browser": "^13.3.0", | |
| 12 | 11 | "@simplewebauthn/server": "^13.3.0", | |
| 13 | 12 | "argon2": "^0.44.0", | |
| 14 | 13 | "elysia": "^1.4.27", | |
| 15 | 14 | "file-type": "^21.3.2", | |
| 16 | 15 | "isomorphic-dompurify": "^3.3.0", | |
| 17 | - | "jxl-rs-polyfill": "^0.1.1", | |
| 18 | - | "kysely": "^0.28.12", | |
| 16 | + | "kysely": "^0.28.14", | |
| 19 | 17 | "kysely-bun-sqlite": "^0.4.0", | |
| 20 | 18 | "linguist-languages": "^9.3.1", | |
| 21 | 19 | "marked": "^17.0.4", | |
| @@ -25,9 +23,11 @@ | |||
|---|---|---|---|
| 25 | 23 | }, | |
| 26 | 24 | "devDependencies": { | |
| 27 | 25 | "@biomejs/biome": "^2.4.7", | |
| 26 | + | "@simplewebauthn/browser": "^13.3.0", | |
| 28 | 27 | "@types/argon2": "^0.15.4", | |
| 29 | 28 | "@types/bun": "latest", | |
| 30 | 29 | "@types/ssh2": "^1.15.5", | |
| 30 | + | "jxl-rs-polyfill": "^0.1.1", | |
| 31 | 31 | "lightningcss": "^1.32.0", | |
| 32 | 32 | "playwright": "^1.58.2", | |
| 33 | 33 | }, | |
| @@ -303,7 +303,7 @@ | |||
|---|---|---|---|
| 303 | 303 | ||
| 304 | 304 | "jxl-rs-polyfill": ["jxl-rs-polyfill@0.1.1", "", {}, "sha512-BFrrvbnInmDYqEcZZZ2nYJq248VMq6BBBcnc7uhAndz1uhIEclW6uMPLQjqTrEVcDg1JeRhq796kuSc+C/JasQ=="], | |
| 305 | 305 | ||
| 306 | - | "kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="], | |
| 306 | + | "kysely": ["kysely@0.28.14", "", {}, "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA=="], | |
| 307 | 307 | ||
| 308 | 308 | "kysely-bun-sqlite": ["kysely-bun-sqlite@0.4.0", "", { "dependencies": { "bun-types": "^1.1.31" }, "peerDependencies": { "kysely": "^0.28.2" } }, "sha512-2EkQE5sT4ewiw7IWfJsAkpxJ/QPVKXKO5sRYI/xjjJIJlECuOdtG+ssYM0twZJySrdrmuildNPFYVreyu1EdZg=="], | |
| 309 | 309 | ||
Mpackage.json
| @@ -7,7 +7,7 @@ | |||
|---|---|---|---|
| 7 | 7 | "css:build": "bun scripts/build-css.ts", | |
| 8 | 8 | "db:init": "bun run src/db/init.ts", | |
| 9 | 9 | "db:seed": "bun run src/db/seed.ts", | |
| 10 | - | "test": "bun test --timeout 60000", | |
| 10 | + | "test": "bun scripts/test.ts", | |
| 11 | 11 | "vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm", | |
| 12 | 12 | "vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js", | |
| 13 | 13 | "postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill && bun run css:build", | |
| @@ -38,7 +38,7 @@ | |||
|---|---|---|---|
| 38 | 38 | "elysia": "^1.4.27", | |
| 39 | 39 | "file-type": "^21.3.2", | |
| 40 | 40 | "isomorphic-dompurify": "^3.3.0", | |
| 41 | - | "kysely": "^0.28.12", | |
| 41 | + | "kysely": "^0.28.14", | |
| 42 | 42 | "kysely-bun-sqlite": "^0.4.0", | |
| 43 | 43 | "linguist-languages": "^9.3.1", | |
| 44 | 44 | "marked": "^17.0.4", | |
Mscripts/build-css.ts
| @@ -1,14 +1,12 @@ | |||
|---|---|---|---|
| 1 | 1 | import { watch } from "fs"; | |
| 2 | - | import { transform } from "lightningcss"; | |
| 2 | + | import { bundle } from "lightningcss"; | |
| 3 | 3 | ||
| 4 | 4 | const INPUT = "src/styles/main.css"; | |
| 5 | 5 | const OUTPUT = "public/assets/main.css"; | |
| 6 | 6 | ||
| 7 | 7 | async function build() { | |
| 8 | - | const source = Bun.file(INPUT); | |
| 9 | - | const { code } = transform({ | |
| 8 | + | const { code } = bundle({ | |
| 10 | 9 | filename: INPUT, | |
| 11 | - | code: await source.bytes(), | |
| 12 | 10 | minify: true, | |
| 13 | 11 | sourceMap: false, | |
| 14 | 12 | }); | |
| @@ -19,12 +17,12 @@ async function build() { | |||
|---|---|---|---|
| 19 | 17 | await build(); | |
| 20 | 18 | ||
| 21 | 19 | if (process.argv.includes("--watch")) { | |
| 22 | - | watch(INPUT, async () => { | |
| 20 | + | watch("src/styles", { recursive: true }, async () => { | |
| 23 | 21 | try { | |
| 24 | 22 | await build(); | |
| 25 | 23 | } catch (e) { | |
| 26 | 24 | console.error("[css]", e); | |
| 27 | 25 | } | |
| 28 | 26 | }); | |
| 29 | - | console.log(`[css] watching ${INPUT}`); | |
| 27 | + | console.log(`[css] watching src/styles`); | |
| 30 | 28 | } | |
Mscripts/dev.ts
| @@ -1,9 +1,41 @@ | |||
|---|---|---|---|
| 1 | - | const server = Bun.spawn(["bun", "--watch", "src/index.tsx"], { stdio: ["inherit", "inherit", "inherit"] }); | |
| 2 | - | const css = Bun.spawn(["bun", "scripts/build-css.ts", "--watch"], { stdio: ["inherit", "inherit", "inherit"] }); | |
| 1 | + | import { watch } from 'fs'; | |
| 3 | 2 | ||
| 4 | - | process.on("SIGINT", () => { | |
| 3 | + | const SRC_DIR = 'src'; | |
| 4 | + | ||
| 5 | + | async function spawnServer() { | |
| 6 | + | return Bun.spawn(['bun', 'run', 'src/index.tsx'], { | |
| 7 | + | stdio: ['inherit', 'inherit', 'inherit'], | |
| 8 | + | }); | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | async function restart(current: ReturnType<typeof Bun.spawn>) { | |
| 12 | + | current.kill(); | |
| 13 | + | await current.exited; | |
| 14 | + | return spawnServer(); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | let server = await spawnServer(); | |
| 18 | + | ||
| 19 | + | // Debounce rapid file-save bursts (e.g. formatter touching many files at once) | |
| 20 | + | let debounce: ReturnType<typeof setTimeout> | null = null; | |
| 21 | + | watch(SRC_DIR, { recursive: true }, (_, filename) => { | |
| 22 | + | if (filename?.endsWith('.css')) return; // CSS is handled by build-css.ts --watch | |
| 23 | + | if (debounce) clearTimeout(debounce); | |
| 24 | + | debounce = setTimeout(async () => { | |
| 25 | + | console.clear(); | |
| 26 | + | console.log(`[dev] ${filename} changed — restarting…`); | |
| 27 | + | server = await restart(server); | |
| 28 | + | }, 50); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | const css = Bun.spawn(['bun', 'scripts/build-css.ts', '--watch'], { | |
| 32 | + | stdio: ['inherit', 'inherit', 'inherit'], | |
| 33 | + | }); | |
| 34 | + | ||
| 35 | + | process.on('SIGINT', async () => { | |
| 5 | 36 | server.kill(); | |
| 6 | 37 | css.kill(); | |
| 38 | + | await Promise.all([server.exited, css.exited]); | |
| 7 | 39 | process.exit(0); | |
| 8 | 40 | }); | |
| 9 | 41 | ||
Ascripts/test.ts
| @@ -0,0 +1,26 @@ | |||
|---|---|---|---|
| 1 | + | import { readdirSync } from 'fs'; | |
| 2 | + | import { execFileSync } from 'child_process'; | |
| 3 | + | import path from 'path'; | |
| 4 | + | ||
| 5 | + | const testsDir = path.resolve('tests'); | |
| 6 | + | const files = readdirSync(testsDir) | |
| 7 | + | .filter(f => f.endsWith('.test.ts')) | |
| 8 | + | .sort(); | |
| 9 | + | ||
| 10 | + | let passed = 0; | |
| 11 | + | let failed = 0; | |
| 12 | + | ||
| 13 | + | for (const file of files) { | |
| 14 | + | const filePath = path.join(testsDir, file); | |
| 15 | + | try { | |
| 16 | + | execFileSync('bun', ['test', '--bail=1', '--timeout', '30000', filePath], { | |
| 17 | + | stdio: 'inherit', | |
| 18 | + | }); | |
| 19 | + | passed++; | |
| 20 | + | } catch { | |
| 21 | + | failed++; | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | console.log(`\n${passed + failed} test files: ${passed} passed${failed ? `, ${failed} failed` : ''}`); | |
| 26 | + | if (failed > 0) process.exit(1); | |
Asrc/app.ts
| @@ -0,0 +1,35 @@ | |||
|---|---|---|---|
| 1 | + | import path from "node:path"; | |
| 2 | + | import { staticPlugin } from "@elysiajs/static"; | |
| 3 | + | import { Elysia } from "elysia"; | |
| 4 | + | import config from "./config.ts"; | |
| 5 | + | import { authRoutes } from "./routes/auth.tsx"; | |
| 6 | + | import { avatarRoutes } from "./routes/avatars.ts"; | |
| 7 | + | import { gitRoutes } from "./routes/git.ts"; | |
| 8 | + | import { issueRoutes } from "./routes/issues.tsx"; | |
| 9 | + | import { patchRoutes } from "./routes/patches.tsx"; | |
| 10 | + | import { releasesRoutes } from "./routes/releases.tsx"; | |
| 11 | + | import { repoRoutes } from "./routes/repos.tsx"; | |
| 12 | + | import { settingsRoutes } from "./routes/settings.tsx"; | |
| 13 | + | import { syncStartup } from "./services/repoSync.ts"; | |
| 14 | + | ||
| 15 | + | export async function createApp(port: number) { | |
| 16 | + | await syncStartup(); | |
| 17 | + | return new Elysia({ | |
| 18 | + | serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES }, | |
| 19 | + | }) | |
| 20 | + | .use( | |
| 21 | + | staticPlugin({ | |
| 22 | + | assets: path.resolve("./public"), | |
| 23 | + | prefix: "/", | |
| 24 | + | }), | |
| 25 | + | ) | |
| 26 | + | .use(gitRoutes) | |
| 27 | + | .use(settingsRoutes) | |
| 28 | + | .use(authRoutes) | |
| 29 | + | .use(repoRoutes) | |
| 30 | + | .use(issueRoutes) | |
| 31 | + | .use(patchRoutes) | |
| 32 | + | .use(releasesRoutes) | |
| 33 | + | .use(avatarRoutes) | |
| 34 | + | .listen(port); | |
| 35 | + | } | |
Msrc/config.ts
| @@ -1,30 +1,37 @@ | |||
|---|---|---|---|
| 1 | 1 | import path from "node:path"; | |
| 2 | 2 | ||
| 3 | - | //env configs | |
| 4 | - | export const OWNER_DISPLAY_NAME = process.env.OWNER_DISPLAY_NAME ?? "Admin"; | |
| 5 | - | export const INLINE_MAX_BYTES = | |
| 6 | - | parseInt(process.env.INLINE_MAX_BYTES ?? "", 10) || 524288; | |
| 7 | - | export const MAX_UPLOAD_BYTES = | |
| 8 | - | parseInt(process.env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024; | |
| 9 | - | export const MAX_USER_UPLOAD_BYTES = | |
| 10 | - | parseInt(process.env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024; | |
| 11 | - | export const TRUSTED_PROXY = !!process.env.TRUSTED_PROXY; | |
| 12 | - | export const RATE_LIMIT_DISABLED = !!process.env.RATE_LIMIT_DISABLED; | |
| 13 | - | export const SSH_DISABLED = !!process.env.SSH_DISABLED; | |
| 14 | - | export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000; | |
| 15 | - | export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222; | |
| 16 | - | export const REGISTRATION_TYPE = (process.env.REGISTRATION_TYPE ?? "enabled") as | |
| 17 | - | | "enabled" | |
| 18 | - | | "disabled" | |
| 19 | - | | "queue"; | |
| 20 | - | export const REGISTER_QUESTION = process.env.REGISTER_QUESTION ?? ""; | |
| 21 | - | export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`; | |
| 22 | - | export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data"); | |
| 23 | - | export const HIGHLIGHT_WORKERS = | |
| 24 | - | parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4; | |
| 25 | - | export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME; | |
| 26 | - | export const COMMITTER_EMAIL = | |
| 27 | - | process.env.COMMITTER_EMAIL ?? | |
| 28 | - | `${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`; | |
| 29 | - | export const EXTRA_ALLOWED_SIGNERS_PATH = | |
| 30 | - | process.env.EXTRA_ALLOWED_SIGNERS_PATH ?? null; | |
| 3 | + | const env = process.env; | |
| 4 | + | ||
| 5 | + | const config = { | |
| 6 | + | OWNER_DISPLAY_NAME: env.OWNER_DISPLAY_NAME ?? "Admin", | |
| 7 | + | INLINE_MAX_BYTES: parseInt(env.INLINE_MAX_BYTES ?? "", 10) || 524288, | |
| 8 | + | MAX_UPLOAD_BYTES: | |
| 9 | + | parseInt(env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024, | |
| 10 | + | MAX_USER_UPLOAD_BYTES: | |
| 11 | + | parseInt(env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024, | |
| 12 | + | TRUSTED_PROXY: !!env.TRUSTED_PROXY, | |
| 13 | + | RATE_LIMIT_DISABLED: !!env.RATE_LIMIT_DISABLED, | |
| 14 | + | SSH_DISABLED: !!env.SSH_DISABLED, | |
| 15 | + | PORT: parseInt(env.PORT ?? "", 10) || 3000, | |
| 16 | + | SSH_PORT: parseInt(env.SSH_PORT ?? "", 10) || 2222, | |
| 17 | + | REGISTRATION_TYPE: (env.REGISTRATION_TYPE ?? "enabled") as | |
| 18 | + | | "enabled" | |
| 19 | + | | "disabled" | |
| 20 | + | | "queue", | |
| 21 | + | REGISTER_QUESTION: env.REGISTER_QUESTION ?? "", | |
| 22 | + | BASE_URL: | |
| 23 | + | env.BASE_URL ?? | |
| 24 | + | `http://localhost:${parseInt(env.PORT ?? "", 10) || 3000}`, | |
| 25 | + | DATA_DIR: path.resolve(env.DATA_DIR ?? "./data"), | |
| 26 | + | HIGHLIGHT_WORKERS: parseInt(env.HIGHLIGHT_WORKERS ?? "", 10) || 4, | |
| 27 | + | COMMITTER_NAME: env.COMMITTER_NAME ?? env.OWNER_DISPLAY_NAME ?? "Admin", | |
| 28 | + | COMMITTER_EMAIL: "", | |
| 29 | + | EXTRA_ALLOWED_SIGNERS_PATH: env.EXTRA_ALLOWED_SIGNERS_PATH ?? null, | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | // Derived values that depend on other config fields | |
| 33 | + | config.COMMITTER_EMAIL = | |
| 34 | + | env.COMMITTER_EMAIL ?? | |
| 35 | + | `${config.OWNER_DISPLAY_NAME}@${new URL(config.BASE_URL).hostname}`; | |
| 36 | + | ||
| 37 | + | export default config; | |
Msrc/constants.ts
| @@ -1,8 +1,8 @@ | |||
|---|---|---|---|
| 1 | 1 | import path from "node:path"; | |
| 2 | - | import { DATA_DIR, OWNER_DISPLAY_NAME } from "./config.ts"; | |
| 2 | + | import config from "./config.ts"; | |
| 3 | 3 | ||
| 4 | 4 | // Auth / identity | |
| 5 | - | export const WEBAUTHN_RP_NAME = `${OWNER_DISPLAY_NAME}'s Hearthforge`; | |
| 5 | + | export const WEBAUTHN_RP_NAME = `${config.OWNER_DISPLAY_NAME}'s Hearthforge`; | |
| 6 | 6 | export const ADMIN_USERNAME = "admin"; | |
| 7 | 7 | export const VALID_USERNAME_RE = /^[a-zA-Z0-9_-]+$/; | |
| 8 | 8 | export const VALID_REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/; | |
| @@ -39,11 +39,27 @@ export const MAX_FILE_CACHE = 500; | |||
|---|---|---|---|
| 39 | 39 | export const MAX_DIFF_CACHE = 500; | |
| 40 | 40 | export const MAX_PATCH_CACHE = 100; | |
| 41 | 41 | ||
| 42 | - | // Paths | |
| 43 | - | export const DB_PATH = path.join(DATA_DIR, "hearthforge.db"); | |
| 44 | - | export const REPOS_DIR = path.join(DATA_DIR, "repos"); | |
| 45 | - | export const AVATARS_DIR = path.join(DATA_DIR, "avatars"); | |
| 46 | - | export const RELEASES_DIR = path.join(DATA_DIR, "releases"); | |
| 47 | - | export const SSH_HOST_KEY_PATH = | |
| 48 | - | process.env.SSH_HOST_KEY_PATH ?? path.join(DATA_DIR, "ssh_host_key"); | |
| 49 | - | export const ALLOWED_SIGNERS_PATH = path.join(DATA_DIR, "allowed_signers"); | |
| 42 | + | // Paths — derived from config.DATA_DIR via getters so they reflect overrides. | |
| 43 | + | export const paths = { | |
| 44 | + | get DB_PATH() { | |
| 45 | + | return path.join(config.DATA_DIR, "hearthforge.db"); | |
| 46 | + | }, | |
| 47 | + | get REPOS_DIR() { | |
| 48 | + | return path.join(config.DATA_DIR, "repos"); | |
| 49 | + | }, | |
| 50 | + | get AVATARS_DIR() { | |
| 51 | + | return path.join(config.DATA_DIR, "avatars"); | |
| 52 | + | }, | |
| 53 | + | get RELEASES_DIR() { | |
| 54 | + | return path.join(config.DATA_DIR, "releases"); | |
| 55 | + | }, | |
| 56 | + | get SSH_HOST_KEY_PATH() { | |
| 57 | + | return ( | |
| 58 | + | process.env.SSH_HOST_KEY_PATH ?? | |
| 59 | + | path.join(config.DATA_DIR, "ssh_host_key") | |
| 60 | + | ); | |
| 61 | + | }, | |
| 62 | + | get ALLOWED_SIGNERS_PATH() { | |
| 63 | + | return path.join(config.DATA_DIR, "allowed_signers"); | |
| 64 | + | }, | |
| 65 | + | }; | |
Msrc/db/index.ts
| @@ -2,7 +2,7 @@ import { Database as BunDatabase } from "bun:sqlite"; | |||
|---|---|---|---|
| 2 | 2 | import { type Generated, Kysely, type Selectable } from "kysely"; | |
| 3 | 3 | import { BunSqliteDialect } from "kysely-bun-sqlite"; | |
| 4 | 4 | ||
| 5 | - | import { DB_PATH } from "../constants.ts"; | |
| 5 | + | import { paths } from "../constants.ts"; | |
| 6 | 6 | ||
| 7 | 7 | interface UserTable { | |
| 8 | 8 | id: Generated<number>; | |
| @@ -190,7 +190,7 @@ export type ReleaseRow = Selectable<ReleaseTable>; | |||
|---|---|---|---|
| 190 | 190 | export type ReleaseAssetRow = Selectable<ReleaseAssetTable>; | |
| 191 | 191 | export type LabelRow = Selectable<LabelTable>; | |
| 192 | 192 | ||
| 193 | - | const sqlite = new BunDatabase(DB_PATH); | |
| 193 | + | let sqlite = new BunDatabase(paths.DB_PATH); | |
| 194 | 194 | sqlite.run("PRAGMA journal_mode=WAL"); | |
| 195 | 195 | sqlite.run("PRAGMA foreign_keys=ON"); | |
| 196 | 196 | ||
| @@ -220,10 +220,23 @@ sqlite.run( | |||
|---|---|---|---|
| 220 | 220 | "UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''", | |
| 221 | 221 | ); | |
| 222 | 222 | ||
| 223 | - | export const db = new Kysely<Database>({ | |
| 223 | + | export let db = new Kysely<Database>({ | |
| 224 | 224 | dialect: new BunSqliteDialect({ database: sqlite }), | |
| 225 | 225 | }); | |
| 226 | 226 | ||
| 227 | + | /** Close the current DB and reopen from disk (used by tests after data wipe). */ | |
| 228 | + | export function resetDb() { | |
| 229 | + | try { | |
| 230 | + | sqlite.close(); | |
| 231 | + | } catch {} | |
| 232 | + | sqlite = new BunDatabase(paths.DB_PATH); | |
| 233 | + | sqlite.run("PRAGMA journal_mode=WAL"); | |
| 234 | + | sqlite.run("PRAGMA foreign_keys=ON"); | |
| 235 | + | db = new Kysely<Database>({ | |
| 236 | + | dialect: new BunSqliteDialect({ database: sqlite }), | |
| 237 | + | }); | |
| 238 | + | } | |
| 239 | + | ||
| 227 | 240 | // Migration: add allow_user_labels column to repositories if missing | |
| 228 | 241 | const repoCols = sqlite | |
| 229 | 242 | .query<{ name: string }, []>("PRAGMA table_info(repositories)") | |
Msrc/db/init.ts
| @@ -2,20 +2,15 @@ import { Database } from "bun:sqlite"; | |||
|---|---|---|---|
| 2 | 2 | import { mkdirSync, readFileSync } from "node:fs"; | |
| 3 | 3 | import path from "node:path"; | |
| 4 | 4 | import * as argon2 from "argon2"; | |
| 5 | - | import { DATA_DIR } from "../config.ts"; | |
| 6 | - | import { | |
| 7 | - | ADMIN_USERNAME, | |
| 8 | - | AVATARS_DIR, | |
| 9 | - | DB_PATH, | |
| 10 | - | REPOS_DIR, | |
| 11 | - | } from "../constants.ts"; | |
| 5 | + | import config from "../config.ts"; | |
| 6 | + | import { ADMIN_USERNAME, paths } from "../constants.ts"; | |
| 12 | 7 | ||
| 13 | 8 | // Ensure data directories exist | |
| 14 | - | mkdirSync(DATA_DIR, { recursive: true }); | |
| 15 | - | mkdirSync(REPOS_DIR, { recursive: true }); | |
| 16 | - | mkdirSync(AVATARS_DIR, { recursive: true }); | |
| 9 | + | mkdirSync(config.DATA_DIR, { recursive: true }); | |
| 10 | + | mkdirSync(paths.REPOS_DIR, { recursive: true }); | |
| 11 | + | mkdirSync(paths.AVATARS_DIR, { recursive: true }); | |
| 17 | 12 | ||
| 18 | - | const db = new Database(DB_PATH); | |
| 13 | + | const db = new Database(paths.DB_PATH); | |
| 19 | 14 | db.run("PRAGMA journal_mode=WAL"); | |
| 20 | 15 | db.run("PRAGMA foreign_keys=ON"); | |
| 21 | 16 | ||
| @@ -47,5 +42,5 @@ if (!existing) { | |||
|---|---|---|---|
| 47 | 42 | console.log("Admin account already exists."); | |
| 48 | 43 | } | |
| 49 | 44 | ||
| 50 | - | console.log("Database initialized at", DB_PATH); | |
| 45 | + | console.log("Database initialized at", paths.DB_PATH); | |
| 51 | 46 | db.close(); | |
Msrc/index.tsx
| @@ -1,16 +1,5 @@ | |||
|---|---|---|---|
| 1 | - | import path from "node:path"; | |
| 2 | - | import { staticPlugin } from "@elysiajs/static"; | |
| 3 | - | import { Elysia } from "elysia"; | |
| 4 | - | import { MAX_UPLOAD_BYTES, PORT, SSH_DISABLED } from "./config.ts"; | |
| 5 | - | import { authRoutes } from "./routes/auth.tsx"; | |
| 6 | - | import { avatarRoutes } from "./routes/avatars.ts"; | |
| 7 | - | import { gitRoutes } from "./routes/git.ts"; | |
| 8 | - | import { issueRoutes } from "./routes/issues.tsx"; | |
| 9 | - | import { patchRoutes } from "./routes/patches.tsx"; | |
| 10 | - | import { releasesRoutes } from "./routes/releases.tsx"; | |
| 11 | - | import { repoRoutes } from "./routes/repos.tsx"; | |
| 12 | - | import { settingsRoutes } from "./routes/settings.tsx"; | |
| 13 | - | import { syncStartup } from "./services/repoSync.ts"; | |
| 1 | + | import { createApp } from "./app.ts"; | |
| 2 | + | import config from "./config.ts"; | |
| 14 | 3 | import { startSshServer } from "./services/sshServer.ts"; | |
| 15 | 4 | ||
| 16 | 5 | for (const cmd of ["git", "ssh-keygen"]) { | |
| @@ -21,22 +10,6 @@ for (const cmd of ["git", "ssh-keygen"]) { | |||
|---|---|---|---|
| 21 | 10 | } | |
| 22 | 11 | } | |
| 23 | 12 | ||
| 24 | - | await syncStartup(); | |
| 25 | - | if (!SSH_DISABLED) await startSshServer(); | |
| 26 | - | new Elysia({ serve: { maxRequestBodySize: MAX_UPLOAD_BYTES } }) | |
| 27 | - | .use( | |
| 28 | - | staticPlugin({ | |
| 29 | - | assets: path.resolve("./public"), | |
| 30 | - | prefix: "/", | |
| 31 | - | }), | |
| 32 | - | ) | |
| 33 | - | .use(gitRoutes) | |
| 34 | - | .use(settingsRoutes) | |
| 35 | - | .use(authRoutes) | |
| 36 | - | .use(repoRoutes) | |
| 37 | - | .use(issueRoutes) | |
| 38 | - | .use(patchRoutes) | |
| 39 | - | .use(releasesRoutes) | |
| 40 | - | .use(avatarRoutes) | |
| 41 | - | .listen(PORT); | |
| 42 | - | console.log(`Hearthforge running at http://localhost:${PORT}`); | |
| 13 | + | if (!config.SSH_DISABLED) await startSshServer(); | |
| 14 | + | await createApp(config.PORT); | |
| 15 | + | console.log(`Hearthforge running at http://localhost:${config.PORT}`); | |
Msrc/lib/rateLimiter.ts
| @@ -1,4 +1,4 @@ | |||
|---|---|---|---|
| 1 | - | import { RATE_LIMIT_DISABLED, TRUSTED_PROXY } from "../config.ts"; | |
| 1 | + | import config from "../config.ts"; | |
| 2 | 2 | ||
| 3 | 3 | interface Bucket { | |
| 4 | 4 | count: number; | |
| @@ -12,7 +12,7 @@ export function checkRateLimit( | |||
|---|---|---|---|
| 12 | 12 | maxRequests: number, | |
| 13 | 13 | windowMs: number, | |
| 14 | 14 | ): boolean { | |
| 15 | - | if (RATE_LIMIT_DISABLED || !ip) return true; | |
| 15 | + | if (config.RATE_LIMIT_DISABLED || !ip) return true; | |
| 16 | 16 | const now = Date.now(); | |
| 17 | 17 | const bucket = buckets.get(ip); | |
| 18 | 18 | if (!bucket || now > bucket.resetAt) { | |
| @@ -28,7 +28,7 @@ export function getClientIp( | |||
|---|---|---|---|
| 28 | 28 | request: Request, | |
| 29 | 29 | server: Bun.Server<unknown> | null, | |
| 30 | 30 | ): string | null { | |
| 31 | - | if (TRUSTED_PROXY) { | |
| 31 | + | if (config.TRUSTED_PROXY) { | |
| 32 | 32 | return ( | |
| 33 | 33 | request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? | |
| 34 | 34 | null | |
Msrc/lib/users.ts
| @@ -1,8 +1,8 @@ | |||
|---|---|---|---|
| 1 | - | import { OWNER_DISPLAY_NAME } from "../config.ts"; | |
| 1 | + | import config from "../config.ts"; | |
| 2 | 2 | import { ADMIN_USERNAME } from "../constants.ts"; | |
| 3 | 3 | ||
| 4 | 4 | /** Show OWNER_DISPLAY_NAME for the admin account, [Deleted user] for null, otherwise the raw username. */ | |
| 5 | 5 | export function displayName(username: string | null | undefined): string { | |
| 6 | 6 | if (!username) return "[Deleted user]"; | |
| 7 | - | return username === ADMIN_USERNAME ? OWNER_DISPLAY_NAME : username; | |
| 7 | + | return username === ADMIN_USERNAME ? config.OWNER_DISPLAY_NAME : username; | |
| 8 | 8 | } | |
Msrc/routes/auth.tsx
| @@ -6,7 +6,7 @@ import { | |||
|---|---|---|---|
| 6 | 6 | } from "@simplewebauthn/server"; | |
| 7 | 7 | import * as argon2 from "argon2"; | |
| 8 | 8 | import { Elysia, t } from "elysia"; | |
| 9 | - | import { REGISTER_QUESTION, REGISTRATION_TYPE } from "../config.ts"; | |
| 9 | + | import config from "../config.ts"; | |
| 10 | 10 | import { | |
| 11 | 11 | ADMIN_USERNAME, | |
| 12 | 12 | CHALLENGE_TTL_MS, | |
| @@ -130,15 +130,15 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 130 | 130 | ) | |
| 131 | 131 | ||
| 132 | 132 | .get("/register", () => { | |
| 133 | - | if (REGISTRATION_TYPE === "disabled") | |
| 133 | + | if (config.REGISTRATION_TYPE === "disabled") | |
| 134 | 134 | return new Response("Registration is disabled", { status: 403 }); | |
| 135 | - | return html(<Register question={REGISTER_QUESTION} />); | |
| 135 | + | return html(<Register question={config.REGISTER_QUESTION} />); | |
| 136 | 136 | }) | |
| 137 | 137 | ||
| 138 | 138 | .post( | |
| 139 | 139 | "/register", | |
| 140 | 140 | async ({ body, request, server }) => { | |
| 141 | - | if (REGISTRATION_TYPE === "disabled") | |
| 141 | + | if (config.REGISTRATION_TYPE === "disabled") | |
| 142 | 142 | return new Response("Registration is disabled", { | |
| 143 | 143 | status: 403, | |
| 144 | 144 | }); | |
| @@ -147,7 +147,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 147 | 147 | return html( | |
| 148 | 148 | <Register | |
| 149 | 149 | error="Too many registration attempts. Please try again later." | |
| 150 | - | question={REGISTER_QUESTION} | |
| 150 | + | question={config.REGISTER_QUESTION} | |
| 151 | 151 | />, | |
| 152 | 152 | ); | |
| 153 | 153 | } | |
| @@ -157,7 +157,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 157 | 157 | return html( | |
| 158 | 158 | <Register | |
| 159 | 159 | error="Username may only contain letters, numbers, hyphens, and underscores" | |
| 160 | - | question={REGISTER_QUESTION} | |
| 160 | + | question={config.REGISTER_QUESTION} | |
| 161 | 161 | />, | |
| 162 | 162 | ); | |
| 163 | 163 | } | |
| @@ -165,7 +165,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 165 | 165 | return html( | |
| 166 | 166 | <Register | |
| 167 | 167 | error="That username is reserved" | |
| 168 | - | question={REGISTER_QUESTION} | |
| 168 | + | question={config.REGISTER_QUESTION} | |
| 169 | 169 | />, | |
| 170 | 170 | ); | |
| 171 | 171 | } | |
| @@ -174,7 +174,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 174 | 174 | return html( | |
| 175 | 175 | <Register | |
| 176 | 176 | error="Password is required (use the passkey button for passwordless registration)" | |
| 177 | - | question={REGISTER_QUESTION} | |
| 177 | + | question={config.REGISTER_QUESTION} | |
| 178 | 178 | />, | |
| 179 | 179 | ); | |
| 180 | 180 | } | |
| @@ -182,7 +182,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 182 | 182 | return html( | |
| 183 | 183 | <Register | |
| 184 | 184 | error="Passwords do not match" | |
| 185 | - | question={REGISTER_QUESTION} | |
| 185 | + | question={config.REGISTER_QUESTION} | |
| 186 | 186 | />, | |
| 187 | 187 | ); | |
| 188 | 188 | } | |
| @@ -190,14 +190,14 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 190 | 190 | return html( | |
| 191 | 191 | <Register | |
| 192 | 192 | error="Password must be at least 8 characters" | |
| 193 | - | question={REGISTER_QUESTION} | |
| 193 | + | question={config.REGISTER_QUESTION} | |
| 194 | 194 | />, | |
| 195 | 195 | ); | |
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | 198 | const hash = await argon2.hash(password); | |
| 199 | 199 | const now = new Date().toISOString(); | |
| 200 | - | const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0; | |
| 200 | + | const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0; | |
| 201 | 201 | let result: { id: number }; | |
| 202 | 202 | try { | |
| 203 | 203 | result = await db | |
| @@ -221,16 +221,19 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 221 | 221 | return html( | |
| 222 | 222 | <Register | |
| 223 | 223 | error="Username already taken" | |
| 224 | - | question={REGISTER_QUESTION} | |
| 224 | + | question={config.REGISTER_QUESTION} | |
| 225 | 225 | />, | |
| 226 | 226 | ); | |
| 227 | 227 | } | |
| 228 | 228 | throw err; | |
| 229 | 229 | } | |
| 230 | 230 | ||
| 231 | - | if (REGISTRATION_TYPE === "queue") { | |
| 231 | + | if (config.REGISTRATION_TYPE === "queue") { | |
| 232 | 232 | return html( | |
| 233 | - | <Register pending={true} question={REGISTER_QUESTION} />, | |
| 233 | + | <Register | |
| 234 | + | pending={true} | |
| 235 | + | question={config.REGISTER_QUESTION} | |
| 236 | + | />, | |
| 234 | 237 | ); | |
| 235 | 238 | } | |
| 236 | 239 | ||
| @@ -262,7 +265,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 262 | 265 | .post( | |
| 263 | 266 | "/auth/passkey/create-user", | |
| 264 | 267 | async ({ body }) => { | |
| 265 | - | if (REGISTRATION_TYPE === "disabled") { | |
| 268 | + | if (config.REGISTRATION_TYPE === "disabled") { | |
| 266 | 269 | return new Response( | |
| 267 | 270 | JSON.stringify({ error: "Registration is disabled" }), | |
| 268 | 271 | { status: 400 }, | |
| @@ -284,7 +287,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 284 | 287 | ); | |
| 285 | 288 | } | |
| 286 | 289 | const now = new Date().toISOString(); | |
| 287 | - | const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0; | |
| 290 | + | const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0; | |
| 288 | 291 | let result: { id: number }; | |
| 289 | 292 | try { | |
| 290 | 293 | result = await db | |
| @@ -313,7 +316,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 313 | 316 | throw err; | |
| 314 | 317 | } | |
| 315 | 318 | ||
| 316 | - | if (REGISTRATION_TYPE === "queue") { | |
| 319 | + | if (config.REGISTRATION_TYPE === "queue") { | |
| 317 | 320 | return new Response( | |
| 318 | 321 | JSON.stringify({ ok: true, pending: true }), | |
| 319 | 322 | { headers: { "Content-Type": "application/json" } }, | |
Msrc/routes/avatars.ts
| @@ -1,5 +1,5 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Elysia, t } from "elysia"; | |
| 2 | - | import { MAX_USER_UPLOAD_BYTES } from "../config.ts"; | |
| 2 | + | import config from "../config.ts"; | |
| 3 | 3 | import { db } from "../db/index.ts"; | |
| 4 | 4 | import { requireAuth, resolveSession } from "../middleware/session.ts"; | |
| 5 | 5 | import { | |
| @@ -55,7 +55,7 @@ export const avatarRoutes = new Elysia() | |||
|---|---|---|---|
| 55 | 55 | const user = await resolveSession(cookie.session.value); | |
| 56 | 56 | const deny = requireAuth(user); | |
| 57 | 57 | if (deny) return deny; | |
| 58 | - | if (body.avatar.size > MAX_USER_UPLOAD_BYTES) { | |
| 58 | + | if (body.avatar.size > config.MAX_USER_UPLOAD_BYTES) { | |
| 59 | 59 | return new Response("Avatar file too large", { status: 400 }); | |
| 60 | 60 | } | |
| 61 | 61 | const buffer = Buffer.from(await body.avatar.arrayBuffer()); | |
Msrc/routes/git.ts
| @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; | |||
|---|---|---|---|
| 2 | 2 | import path from "node:path"; | |
| 3 | 3 | import * as argon2 from "argon2"; | |
| 4 | 4 | import { Elysia, t } from "elysia"; | |
| 5 | - | import { ADMIN_USERNAME, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts"; | |
| 5 | + | import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts"; | |
| 6 | 6 | import { db } from "../db"; | |
| 7 | 7 | ||
| 8 | 8 | function pktLine(str: string): Buffer { | |
| @@ -59,7 +59,7 @@ async function getRepo( | |||
|---|---|---|---|
| 59 | 59 | .where("name", "=", repoName) | |
| 60 | 60 | .executeTakeFirst(); | |
| 61 | 61 | if (!repo) return null; | |
| 62 | - | const repoPath = path.join(REPOS_DIR, `${repo.name}.git`); | |
| 62 | + | const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`); | |
| 63 | 63 | if (!existsSync(repoPath)) return null; | |
| 64 | 64 | return { repoPath, isPrivate: repo.is_private === 1 }; | |
| 65 | 65 | } | |
Msrc/routes/patches.tsx
| @@ -1,10 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Elysia, t } from "elysia"; | |
| 2 | 2 | import { sql } from "kysely"; | |
| 3 | - | import { | |
| 4 | - | COMMITTER_EMAIL, | |
| 5 | - | COMMITTER_NAME, | |
| 6 | - | MAX_USER_UPLOAD_BYTES, | |
| 7 | - | } from "../config.ts"; | |
| 3 | + | import config from "../config.ts"; | |
| 8 | 4 | import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts"; | |
| 9 | 5 | import { db, getRepo, type LabelRow } from "../db/index.ts"; | |
| 10 | 6 | import { | |
| @@ -293,7 +289,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 293 | 289 | ); | |
| 294 | 290 | } | |
| 295 | 291 | ||
| 296 | - | if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) { | |
| 292 | + | if (body.patch_file.size > config.MAX_USER_UPLOAD_BYTES) { | |
| 297 | 293 | return html( | |
| 298 | 294 | <NewPatch | |
| 299 | 295 | user={user!} | |
| @@ -637,8 +633,8 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 637 | 633 | patch.patch_content, | |
| 638 | 634 | mergeMeta.author, | |
| 639 | 635 | mergeMeta.email, | |
| 640 | - | COMMITTER_NAME, | |
| 641 | - | COMMITTER_EMAIL, | |
| 636 | + | config.COMMITTER_NAME, | |
| 637 | + | config.COMMITTER_EMAIL, | |
| 642 | 638 | ); | |
| 643 | 639 | } catch (err) { | |
| 644 | 640 | // Roll back the status if the git operation fails | |
| @@ -689,7 +685,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 689 | 685 | if (!body.patch_file || body.patch_file.size === 0) { | |
| 690 | 686 | return new Response("Patch file is required", { status: 400 }); | |
| 691 | 687 | } | |
| 692 | - | if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) { | |
| 688 | + | if (body.patch_file.size > config.MAX_USER_UPLOAD_BYTES) { | |
| 693 | 689 | return new Response("Patch file is too large", { status: 400 }); | |
| 694 | 690 | } | |
| 695 | 691 | ||
Msrc/routes/releases.tsx
| @@ -1,8 +1,8 @@ | |||
|---|---|---|---|
| 1 | 1 | import { mkdirSync, rmSync } from "node:fs"; | |
| 2 | 2 | import path from "node:path"; | |
| 3 | 3 | import { Elysia, t } from "elysia"; | |
| 4 | - | import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts"; | |
| 5 | - | import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts"; | |
| 4 | + | import config from "../config.ts"; | |
| 5 | + | import { paths, RELEASES_PER_PAGE } from "../constants.ts"; | |
| 6 | 6 | import { db, getRepo } from "../db/index.ts"; | |
| 7 | 7 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 8 | 8 | import { archiveRepo, git } from "../services/git.ts"; | |
| @@ -184,8 +184,8 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 184 | 184 | tagName, | |
| 185 | 185 | revision, | |
| 186 | 186 | tagMessage, | |
| 187 | - | COMMITTER_NAME, | |
| 188 | - | COMMITTER_EMAIL, | |
| 187 | + | config.COMMITTER_NAME, | |
| 188 | + | config.COMMITTER_EMAIL, | |
| 189 | 189 | ); | |
| 190 | 190 | if (tagResult === "already_exists") { | |
| 191 | 191 | return html( | |
| @@ -268,7 +268,7 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 268 | 268 | .executeTakeFirstOrThrow(); | |
| 269 | 269 | ||
| 270 | 270 | const id = inserted.id; | |
| 271 | - | releaseDir = path.join(RELEASES_DIR, String(id)); | |
| 271 | + | releaseDir = path.join(paths.RELEASES_DIR, String(id)); | |
| 272 | 272 | ||
| 273 | 273 | if (includeSource) { | |
| 274 | 274 | const sourceDir = path.join(releaseDir, "source"); | |
| @@ -320,7 +320,7 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 320 | 320 | // is no risk of contaminating a later release. | |
| 321 | 321 | if (includeSource) { | |
| 322 | 322 | const sourceDir = path.join( | |
| 323 | - | RELEASES_DIR, | |
| 323 | + | paths.RELEASES_DIR, | |
| 324 | 324 | String(releaseId), | |
| 325 | 325 | "source", | |
| 326 | 326 | ); | |
| @@ -407,7 +407,7 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 407 | 407 | if (release.include_source_code) { | |
| 408 | 408 | const base = `${repo.name}-${release.tag_name}`; | |
| 409 | 409 | const sourceDir = path.join( | |
| 410 | - | RELEASES_DIR, | |
| 410 | + | paths.RELEASES_DIR, | |
| 411 | 411 | String(release.id), | |
| 412 | 412 | "source", | |
| 413 | 413 | ); | |
| @@ -477,7 +477,10 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 477 | 477 | // Remove files from disk before the DB record so that a crash | |
| 478 | 478 | // between the two leaves a broken-but-visible repo rather than a | |
| 479 | 479 | // DB record pointing to missing files. | |
| 480 | - | const releaseDir = path.join(RELEASES_DIR, String(release.id)); | |
| 480 | + | const releaseDir = path.join( | |
| 481 | + | paths.RELEASES_DIR, | |
| 482 | + | String(release.id), | |
| 483 | + | ); | |
| 481 | 484 | rmSync(releaseDir, { recursive: true, force: true }); | |
| 482 | 485 | await db | |
| 483 | 486 | .deleteFrom("releases") | |
| @@ -522,7 +525,7 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 522 | 525 | if (!asset) return new Response("Not found", { status: 404 }); | |
| 523 | 526 | ||
| 524 | 527 | const filePath = path.join( | |
| 525 | - | RELEASES_DIR, | |
| 528 | + | paths.RELEASES_DIR, | |
| 526 | 529 | String(release.id), | |
| 527 | 530 | "assets", | |
| 528 | 531 | safeFilename, | |
| @@ -565,7 +568,7 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 565 | 568 | ||
| 566 | 569 | const safeFilename = path.basename(params.filename); | |
| 567 | 570 | const filePath = path.join( | |
| 568 | - | RELEASES_DIR, | |
| 571 | + | paths.RELEASES_DIR, | |
| 569 | 572 | String(release.id), | |
| 570 | 573 | "source", | |
| 571 | 574 | safeFilename, | |
Msrc/routes/repos.tsx
| @@ -2,10 +2,10 @@ import { readFileSync, rmSync } from "node:fs"; | |||
|---|---|---|---|
| 2 | 2 | import path from "node:path"; | |
| 3 | 3 | import { Elysia, t } from "elysia"; | |
| 4 | 4 | import { fileTypeFromBuffer } from "file-type"; | |
| 5 | - | import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts"; | |
| 5 | + | import config from "../config.ts"; | |
| 6 | 6 | import { | |
| 7 | - | ALLOWED_SIGNERS_PATH, | |
| 8 | 7 | COMMITS_PER_PAGE, | |
| 8 | + | paths, | |
| 9 | 9 | REPOS_PER_PAGE, | |
| 10 | 10 | VALID_REPO_NAME_RE, | |
| 11 | 11 | } from "../constants.ts"; | |
| @@ -78,7 +78,7 @@ async function readReadme( | |||
|---|---|---|---|
| 78 | 78 | ||
| 79 | 79 | export const repoRoutes = new Elysia() | |
| 80 | 80 | .get("/allowed_signers", () => { | |
| 81 | - | return new Response(readFileSync(ALLOWED_SIGNERS_PATH), { | |
| 81 | + | return new Response(readFileSync(paths.ALLOWED_SIGNERS_PATH), { | |
| 82 | 82 | headers: { "Content-Type": "text/plain; charset=utf-8" }, | |
| 83 | 83 | }); | |
| 84 | 84 | }) | |
| @@ -593,8 +593,8 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 593 | 593 | filePath, | |
| 594 | 594 | content, | |
| 595 | 595 | message, | |
| 596 | - | COMMITTER_NAME, | |
| 597 | - | COMMITTER_EMAIL, | |
| 596 | + | config.COMMITTER_NAME, | |
| 597 | + | config.COMMITTER_EMAIL, | |
| 598 | 598 | ); | |
| 599 | 599 | ||
| 600 | 600 | return new Response(null, { | |
Msrc/routes/settings.tsx
| @@ -1,7 +1,7 @@ | |||
|---|---|---|---|
| 1 | 1 | import * as argon2 from "argon2"; | |
| 2 | 2 | import { Elysia, t } from "elysia"; | |
| 3 | 3 | import { utils as sshUtils } from "ssh2"; | |
| 4 | - | import { REGISTRATION_TYPE } from "../config.ts"; | |
| 4 | + | import config from "../config.ts"; | |
| 5 | 5 | import { | |
| 6 | 6 | ADMIN_USERNAME, | |
| 7 | 7 | VALID_KEY_TYPES, | |
| @@ -55,7 +55,7 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 55 | 55 | const hasPassword = !!userRow?.password_hash; | |
| 56 | 56 | ||
| 57 | 57 | const pendingUsers = | |
| 58 | - | user.isAdmin && REGISTRATION_TYPE === "queue" | |
| 58 | + | user.isAdmin && config.REGISTRATION_TYPE === "queue" | |
| 59 | 59 | ? await db | |
| 60 | 60 | .selectFrom("users") | |
| 61 | 61 | .select([ | |
Msrc/services/avatar.ts
| @@ -3,9 +3,9 @@ import path from "node:path"; | |||
|---|---|---|---|
| 3 | 3 | import { encode } from "@jsquash/jxl"; | |
| 4 | 4 | import { fileTypeFromBuffer } from "file-type"; | |
| 5 | 5 | import sharp from "sharp"; | |
| 6 | - | import { AVATARS_DIR } from "../constants.ts"; | |
| 6 | + | import { paths } from "../constants.ts"; | |
| 7 | 7 | ||
| 8 | - | mkdirSync(AVATARS_DIR, { recursive: true }); | |
| 8 | + | mkdirSync(paths.AVATARS_DIR, { recursive: true }); | |
| 9 | 9 | ||
| 10 | 10 | // FNV-1a: produces n deterministic bytes from a string | |
| 11 | 11 | function hashBytes(s: string, n: number): number[] { | |
| @@ -41,7 +41,7 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] { | |||
|---|---|---|---|
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | export function avatarJxlPath(userId: number): string { | |
| 44 | - | return path.join(AVATARS_DIR, `${userId}.jxl`); | |
| 44 | + | return path.join(paths.AVATARS_DIR, `${userId}.jxl`); | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | export async function processAndStoreAvatar( | |
Msrc/services/diffHighlight.ts
| @@ -1,6 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import path from "node:path"; | |
| 2 | 2 | import type { BundledLanguage } from "shiki"; | |
| 3 | - | import { INLINE_MAX_BYTES } from "../config.ts"; | |
| 3 | + | import config from "../config.ts"; | |
| 4 | 4 | import { git } from "./git.ts"; | |
| 5 | 5 | import { detectLang, getHighlighter } from "./highlight.ts"; | |
| 6 | 6 | ||
| @@ -271,7 +271,7 @@ export async function highlightFile( | |||
|---|---|---|---|
| 271 | 271 | 0, | |
| 272 | 272 | ); | |
| 273 | 273 | const highlightedHunks = | |
| 274 | - | totalBytes > INLINE_MAX_BYTES | |
| 274 | + | totalBytes > config.INLINE_MAX_BYTES | |
| 275 | 275 | ? file.hunks.map((hunk) => | |
| 276 | 276 | hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)), | |
| 277 | 277 | ) | |
Msrc/services/git.ts
| @@ -1,11 +1,7 @@ | |||
|---|---|---|---|
| 1 | 1 | import path from "node:path"; | |
| 2 | 2 | import { $ as _$ } from "bun"; | |
| 3 | 3 | ||
| 4 | - | import { | |
| 5 | - | ALLOWED_SIGNERS_PATH, | |
| 6 | - | REPOS_DIR, | |
| 7 | - | SSH_HOST_KEY_PATH, | |
| 8 | - | } from "../constants.ts"; | |
| 4 | + | import { paths } from "../constants.ts"; | |
| 9 | 5 | ||
| 10 | 6 | const gitEnv = { | |
| 11 | 7 | ...process.env, | |
| @@ -45,7 +41,7 @@ async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> { | |||
|---|---|---|---|
| 45 | 41 | } | |
| 46 | 42 | ||
| 47 | 43 | export function repoPath(name: string): string { | |
| 48 | - | return path.join(REPOS_DIR, `${name}.git`); | |
| 44 | + | return path.join(paths.REPOS_DIR, `${name}.git`); | |
| 49 | 45 | } | |
| 50 | 46 | ||
| 51 | 47 | export async function validateCommit( | |
| @@ -268,7 +264,7 @@ export const git = { | |||
|---|---|---|---|
| 268 | 264 | "-c", | |
| 269 | 265 | "gpg.format=ssh", | |
| 270 | 266 | "-c", | |
| 271 | - | `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`, | |
| 267 | + | `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`, | |
| 272 | 268 | ]; | |
| 273 | 269 | try { | |
| 274 | 270 | const out = | |
| @@ -465,7 +461,7 @@ export const git = { | |||
|---|---|---|---|
| 465 | 461 | "-c", | |
| 466 | 462 | "gpg.format=ssh", | |
| 467 | 463 | "-c", | |
| 468 | - | `user.signingKey=${SSH_HOST_KEY_PATH}`, | |
| 464 | + | `user.signingKey=${paths.SSH_HOST_KEY_PATH}`, | |
| 469 | 465 | ]; | |
| 470 | 466 | const commit = ( | |
| 471 | 467 | await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}` | |
| @@ -515,7 +511,7 @@ export const git = { | |||
|---|---|---|---|
| 515 | 511 | "-c", | |
| 516 | 512 | "gpg.format=ssh", | |
| 517 | 513 | "-c", | |
| 518 | - | `user.signingKey=${SSH_HOST_KEY_PATH}`, | |
| 514 | + | `user.signingKey=${paths.SSH_HOST_KEY_PATH}`, | |
| 519 | 515 | ]; | |
| 520 | 516 | const commit = ( | |
| 521 | 517 | await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}` | |
| @@ -550,7 +546,7 @@ export const git = { | |||
|---|---|---|---|
| 550 | 546 | "-c", | |
| 551 | 547 | "gpg.format=ssh", | |
| 552 | 548 | "-c", | |
| 553 | - | `user.signingKey=${SSH_HOST_KEY_PATH}`, | |
| 549 | + | `user.signingKey=${paths.SSH_HOST_KEY_PATH}`, | |
| 554 | 550 | ]; | |
| 555 | 551 | const result = | |
| 556 | 552 | message !== undefined | |
| @@ -606,7 +602,7 @@ export const git = { | |||
|---|---|---|---|
| 606 | 602 | "-c", | |
| 607 | 603 | "gpg.format=ssh", | |
| 608 | 604 | "-c", | |
| 609 | - | `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`, | |
| 605 | + | `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`, | |
| 610 | 606 | ]; | |
| 611 | 607 | try { | |
| 612 | 608 | const [metaOut, msgOut] = await Promise.all([ | |
Msrc/services/highlight.ts
| @@ -7,7 +7,7 @@ import { | |||
|---|---|---|---|
| 7 | 7 | createHighlighter, | |
| 8 | 8 | type Highlighter, | |
| 9 | 9 | } from "shiki"; | |
| 10 | - | import { INLINE_MAX_BYTES } from "../config.ts"; | |
| 10 | + | import config from "../config.ts"; | |
| 11 | 11 | import { MAX_FILE_CACHE } from "../constants.ts"; | |
| 12 | 12 | ||
| 13 | 13 | let highlighter: Highlighter | null = null; | |
| @@ -170,7 +170,7 @@ export async function serveFile( | |||
|---|---|---|---|
| 170 | 170 | if (isBinary) { | |
| 171 | 171 | return { type: "binary", size: content.length }; | |
| 172 | 172 | } | |
| 173 | - | if (content.length > INLINE_MAX_BYTES) { | |
| 173 | + | if (content.length > config.INLINE_MAX_BYTES) { | |
| 174 | 174 | return { type: "download", size: content.length }; | |
| 175 | 175 | } | |
| 176 | 176 | const text = content.toString("utf-8"); | |
Msrc/services/highlightWorker.ts
| @@ -1,4 +1,4 @@ | |||
|---|---|---|---|
| 1 | - | import { HIGHLIGHT_WORKERS } from "../config.ts"; | |
| 1 | + | import config from "../config.ts"; | |
| 2 | 2 | import { MAX_DIFF_CACHE } from "../constants.ts"; | |
| 3 | 3 | import type { ParsedFile, RenderedDiffFile } from "./diffHighlight.ts"; | |
| 4 | 4 | import { parseDiff } from "./diffHighlight.ts"; | |
| @@ -59,7 +59,7 @@ function onMessage(handle: WorkerHandle, event: MessageEvent) { | |||
|---|---|---|---|
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | 61 | const pool: WorkerHandle[] = Array.from( | |
| 62 | - | { length: HIGHLIGHT_WORKERS }, | |
| 62 | + | { length: config.HIGHLIGHT_WORKERS }, | |
| 63 | 63 | createHandle, | |
| 64 | 64 | ); | |
| 65 | 65 | ||
Msrc/services/repoSync.ts
| @@ -8,14 +8,8 @@ import { | |||
|---|---|---|---|
| 8 | 8 | } from "node:fs"; | |
| 9 | 9 | import path from "node:path"; | |
| 10 | 10 | import { $ } from "bun"; | |
| 11 | - | import { BASE_URL, EXTRA_ALLOWED_SIGNERS_PATH } from "../config.ts"; | |
| 12 | - | import { | |
| 13 | - | ALLOWED_SIGNERS_PATH, | |
| 14 | - | RELEASES_DIR, | |
| 15 | - | REPOS_DIR, | |
| 16 | - | SSH_HOST_KEY_PATH, | |
| 17 | - | VALID_REPO_NAME_RE, | |
| 18 | - | } from "../constants.ts"; | |
| 11 | + | import config from "../config.ts"; | |
| 12 | + | import { paths, VALID_REPO_NAME_RE } from "../constants.ts"; | |
| 19 | 13 | import type { RepositoryRow } from "../db/index.ts"; | |
| 20 | 14 | import { db } from "../db/index.ts"; | |
| 21 | 15 | import { git, repoPath } from "../services/git.ts"; | |
| @@ -30,12 +24,15 @@ async function convertNonBareRepo( | |||
|---|---|---|---|
| 30 | 24 | const dotGitPath = path.join(entryPath, ".git"); | |
| 31 | 25 | const hasGitSuffix = entryName.endsWith(".git"); | |
| 32 | 26 | const baseName = hasGitSuffix ? entryName : `${entryName}.git`; | |
| 33 | - | const targetPath = path.join(REPOS_DIR, baseName); | |
| 27 | + | const targetPath = path.join(paths.REPOS_DIR, baseName); | |
| 34 | 28 | ||
| 35 | 29 | try { | |
| 36 | 30 | if (hasGitSuffix) { | |
| 37 | 31 | // Case 2: source and target path are the same dir — use a temp location. | |
| 38 | - | const tmpPath = path.join(REPOS_DIR, `.${entryName}.bare_tmp`); | |
| 32 | + | const tmpPath = path.join( | |
| 33 | + | paths.REPOS_DIR, | |
| 34 | + | `.${entryName}.bare_tmp`, | |
| 35 | + | ); | |
| 39 | 36 | renameSync(dotGitPath, tmpPath); | |
| 40 | 37 | rmSync(entryPath, { recursive: true, force: true }); | |
| 41 | 38 | renameSync(tmpPath, targetPath); | |
| @@ -56,11 +53,11 @@ async function convertNonBareRepo( | |||
|---|---|---|---|
| 56 | 53 | } | |
| 57 | 54 | } | |
| 58 | 55 | ||
| 59 | - | // Scans REPOS_DIR for non-bare repos and converts them before the main sync. | |
| 56 | + | // Scans paths.REPOS_DIR for non-bare repos and converts them before the main sync. | |
| 60 | 57 | async function convertNonBareRepos(): Promise<void> { | |
| 61 | 58 | let entries: Dirent[]; | |
| 62 | 59 | try { | |
| 63 | - | entries = readdirSync(REPOS_DIR, { withFileTypes: true }); | |
| 60 | + | entries = readdirSync(paths.REPOS_DIR, { withFileTypes: true }); | |
| 64 | 61 | } catch { | |
| 65 | 62 | return; | |
| 66 | 63 | } | |
| @@ -68,7 +65,7 @@ async function convertNonBareRepos(): Promise<void> { | |||
|---|---|---|---|
| 68 | 65 | const conversions: Promise<void>[] = []; | |
| 69 | 66 | for (const entry of entries) { | |
| 70 | 67 | if (!entry.isDirectory()) continue; | |
| 71 | - | const entryPath = path.join(REPOS_DIR, entry.name); | |
| 68 | + | const entryPath = path.join(paths.REPOS_DIR, entry.name); | |
| 72 | 69 | if (existsSync(path.join(entryPath, ".git"))) { | |
| 73 | 70 | conversions.push(convertNonBareRepo(entry.name, entryPath)); | |
| 74 | 71 | } | |
| @@ -78,7 +75,7 @@ async function convertNonBareRepos(): Promise<void> { | |||
|---|---|---|---|
| 78 | 75 | ||
| 79 | 76 | export function listDiskRepoNames(): string[] { | |
| 80 | 77 | try { | |
| 81 | - | return readdirSync(REPOS_DIR, { withFileTypes: true }) | |
| 78 | + | return readdirSync(paths.REPOS_DIR, { withFileTypes: true }) | |
| 82 | 79 | .filter((e) => e.isDirectory() && e.name.endsWith(".git")) | |
| 83 | 80 | .map((e) => e.name.slice(0, -4)); | |
| 84 | 81 | } catch { | |
| @@ -115,22 +112,26 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> { | |||
|---|---|---|---|
| 115 | 112 | } | |
| 116 | 113 | ||
| 117 | 114 | async function ensureSigningSetup(): Promise<void> { | |
| 118 | - | const hostname = new URL(BASE_URL).hostname; | |
| 119 | - | const pubKeyPath = `${SSH_HOST_KEY_PATH}.pub`; | |
| 115 | + | const hostname = new URL(config.BASE_URL).hostname; | |
| 116 | + | const pubKeyPath = `${paths.SSH_HOST_KEY_PATH}.pub`; | |
| 120 | 117 | ||
| 121 | - | if (!existsSync(SSH_HOST_KEY_PATH)) { | |
| 122 | - | await Bun.spawn([ | |
| 118 | + | if (!existsSync(paths.SSH_HOST_KEY_PATH)) { | |
| 119 | + | const { spawnSync } = await import("node:child_process"); | |
| 120 | + | spawnSync( | |
| 123 | 121 | "ssh-keygen", | |
| 124 | - | "-t", | |
| 125 | - | "ed25519", | |
| 126 | - | "-N", | |
| 127 | - | "", | |
| 128 | - | "-f", | |
| 129 | - | SSH_HOST_KEY_PATH, | |
| 130 | - | "-C", | |
| 131 | - | hostname, | |
| 132 | - | ]).exited; | |
| 133 | - | console.log("Generated SSH host key at", SSH_HOST_KEY_PATH); | |
| 122 | + | [ | |
| 123 | + | "-t", | |
| 124 | + | "ed25519", | |
| 125 | + | "-N", | |
| 126 | + | "", | |
| 127 | + | "-f", | |
| 128 | + | paths.SSH_HOST_KEY_PATH, | |
| 129 | + | "-C", | |
| 130 | + | hostname, | |
| 131 | + | ], | |
| 132 | + | { stdio: "ignore" }, | |
| 133 | + | ); | |
| 134 | + | console.log("Generated SSH host key at", paths.SSH_HOST_KEY_PATH); | |
| 134 | 135 | } else { | |
| 135 | 136 | try { | |
| 136 | 137 | const existing = readFileSync(pubKeyPath, "utf8").trim(); | |
| @@ -158,19 +159,22 @@ async function ensureSigningSetup(): Promise<void> { | |||
|---|---|---|---|
| 158 | 159 | ||
| 159 | 160 | let content = `* namespaces="git" ${pubKey}\n`; | |
| 160 | 161 | ||
| 161 | - | if (EXTRA_ALLOWED_SIGNERS_PATH) { | |
| 162 | + | if (config.EXTRA_ALLOWED_SIGNERS_PATH) { | |
| 162 | 163 | try { | |
| 163 | - | const extra = readFileSync(EXTRA_ALLOWED_SIGNERS_PATH, "utf8"); | |
| 164 | + | const extra = readFileSync( | |
| 165 | + | config.EXTRA_ALLOWED_SIGNERS_PATH, | |
| 166 | + | "utf8", | |
| 167 | + | ); | |
| 164 | 168 | content += extra.endsWith("\n") ? extra : `${extra}\n`; | |
| 165 | 169 | } catch { | |
| 166 | 170 | console.warn( | |
| 167 | - | "Could not read EXTRA_ALLOWED_SIGNERS_PATH:", | |
| 168 | - | EXTRA_ALLOWED_SIGNERS_PATH, | |
| 171 | + | "Could not read config.EXTRA_ALLOWED_SIGNERS_PATH:", | |
| 172 | + | config.EXTRA_ALLOWED_SIGNERS_PATH, | |
| 169 | 173 | ); | |
| 170 | 174 | } | |
| 171 | 175 | } | |
| 172 | 176 | ||
| 173 | - | await Bun.write(ALLOWED_SIGNERS_PATH, content); | |
| 177 | + | await Bun.write(paths.ALLOWED_SIGNERS_PATH, content); | |
| 174 | 178 | } | |
| 175 | 179 | ||
| 176 | 180 | export async function syncStartup(): Promise<void> { | |
| @@ -219,13 +223,13 @@ export async function syncStartup(): Promise<void> { | |||
|---|---|---|---|
| 219 | 223 | ); | |
| 220 | 224 | ||
| 221 | 225 | try { | |
| 222 | - | for (const entry of readdirSync(RELEASES_DIR, { | |
| 226 | + | for (const entry of readdirSync(paths.RELEASES_DIR, { | |
| 223 | 227 | withFileTypes: true, | |
| 224 | 228 | })) { | |
| 225 | 229 | if (!entry.isDirectory()) continue; | |
| 226 | 230 | const id = Number(entry.name); | |
| 227 | 231 | if (!Number.isNaN(id) && !allReleaseIds.has(id)) { | |
| 228 | - | rmSync(path.join(RELEASES_DIR, entry.name), { | |
| 232 | + | rmSync(path.join(paths.RELEASES_DIR, entry.name), { | |
| 229 | 233 | recursive: true, | |
| 230 | 234 | force: true, | |
| 231 | 235 | }); | |
| @@ -235,7 +239,7 @@ export async function syncStartup(): Promise<void> { | |||
|---|---|---|---|
| 235 | 239 | } | |
| 236 | 240 | } | |
| 237 | 241 | } catch { | |
| 238 | - | // RELEASES_DIR may not exist yet on first run | |
| 242 | + | // paths.RELEASES_DIR may not exist yet on first run | |
| 239 | 243 | } | |
| 240 | 244 | ||
| 241 | 245 | // Only check for missing dirs on releases that should have one. | |
| @@ -260,7 +264,7 @@ export async function syncStartup(): Promise<void> { | |||
|---|---|---|---|
| 260 | 264 | .execute(); | |
| 261 | 265 | ||
| 262 | 266 | const staleReleases = releasesWithDirs.filter( | |
| 263 | - | (r) => !existsSync(path.join(RELEASES_DIR, String(r.id))), | |
| 267 | + | (r) => !existsSync(path.join(paths.RELEASES_DIR, String(r.id))), | |
| 264 | 268 | ); | |
| 265 | 269 | if (staleReleases.length > 0) { | |
| 266 | 270 | await db | |
Msrc/services/sshServer.ts
| @@ -3,8 +3,8 @@ import { createHash } from "node:crypto"; | |||
|---|---|---|---|
| 3 | 3 | import { readFileSync } from "node:fs"; | |
| 4 | 4 | import path from "node:path"; | |
| 5 | 5 | import { Server, utils } from "ssh2"; | |
| 6 | - | import { SSH_PORT } from "../config.ts"; | |
| 7 | - | import { ADMIN_USERNAME, REPOS_DIR, SSH_HOST_KEY_PATH } from "../constants.ts"; | |
| 6 | + | import config from "../config.ts"; | |
| 7 | + | import { ADMIN_USERNAME, paths } from "../constants.ts"; | |
| 8 | 8 | import { db } from "../db/index.ts"; | |
| 9 | 9 | ||
| 10 | 10 | /** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */ | |
| @@ -33,7 +33,7 @@ export function fingerprintFromLine(pubkeyLine: string): string | null { | |||
|---|---|---|---|
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | export async function startSshServer() { | |
| 36 | - | const hostKey = readFileSync(SSH_HOST_KEY_PATH); | |
| 36 | + | const hostKey = readFileSync(paths.SSH_HOST_KEY_PATH); | |
| 37 | 37 | ||
| 38 | 38 | const server = new Server({ hostKeys: [hostKey] }, (client) => { | |
| 39 | 39 | let authedUser: { id: number; username: string } | null = null; | |
| @@ -94,7 +94,10 @@ export async function startSshServer() { | |||
|---|---|---|---|
| 94 | 94 | .executeTakeFirst(); | |
| 95 | 95 | if (!repo) return reject(); | |
| 96 | 96 | ||
| 97 | - | const repoPath = path.join(REPOS_DIR, `${repo.name}.git`); | |
| 97 | + | const repoPath = path.join( | |
| 98 | + | paths.REPOS_DIR, | |
| 99 | + | `${repo.name}.git`, | |
| 100 | + | ); | |
| 98 | 101 | const stream = accept(); | |
| 99 | 102 | ||
| 100 | 103 | if (command === "git-receive-pack") { | |
| @@ -139,7 +142,7 @@ export async function startSshServer() { | |||
|---|---|---|---|
| 139 | 142 | }); | |
| 140 | 143 | }); | |
| 141 | 144 | ||
| 142 | - | server.listen(SSH_PORT, "0.0.0.0", () => { | |
| 143 | - | console.log(`SSH server listening on port ${SSH_PORT}`); | |
| 145 | + | server.listen(config.SSH_PORT, "0.0.0.0", () => { | |
| 146 | + | console.log(`SSH server listening on port ${config.SSH_PORT}`); | |
| 144 | 147 | }); | |
| 145 | 148 | } | |
Asrc/styles/base.css
| @@ -0,0 +1,309 @@ | |||
|---|---|---|---|
| 1 | + | /* View Transitions */ | |
| 2 | + | @view-transition { | |
| 3 | + | navigation: auto; | |
| 4 | + | } | |
| 5 | + | ||
| 6 | + | .site-nav { | |
| 7 | + | view-transition-name: site-nav; | |
| 8 | + | } | |
| 9 | + | .repo-header { | |
| 10 | + | view-transition-name: repo-header; | |
| 11 | + | } | |
| 12 | + | .breadcrumb { | |
| 13 | + | view-transition-name: breadcrumb; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /* ============================================================ | |
| 17 | + | @layer reset | |
| 18 | + | ============================================================ */ | |
| 19 | + | @layer reset { | |
| 20 | + | *, | |
| 21 | + | *::before, | |
| 22 | + | *::after { | |
| 23 | + | box-sizing: border-box; | |
| 24 | + | margin: 0; | |
| 25 | + | padding: 0; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | body { | |
| 29 | + | min-height: 100dvh; | |
| 30 | + | min-width: 500px; | |
| 31 | + | } | |
| 32 | + | img, | |
| 33 | + | video { | |
| 34 | + | max-width: 100%; | |
| 35 | + | height: auto; | |
| 36 | + | } | |
| 37 | + | button, | |
| 38 | + | input, | |
| 39 | + | textarea, | |
| 40 | + | select { | |
| 41 | + | font: inherit; | |
| 42 | + | } | |
| 43 | + | a { | |
| 44 | + | color: inherit; | |
| 45 | + | } | |
| 46 | + | ul, | |
| 47 | + | ol { | |
| 48 | + | list-style: none; | |
| 49 | + | } | |
| 50 | + | h1, | |
| 51 | + | h2, | |
| 52 | + | h3, | |
| 53 | + | h4, | |
| 54 | + | h5, | |
| 55 | + | h6 { | |
| 56 | + | font-weight: 600; | |
| 57 | + | line-height: 1.2; | |
| 58 | + | } | |
| 59 | + | pre, | |
| 60 | + | code { | |
| 61 | + | font-family: var(--font-mono); | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /* ============================================================ | |
| 66 | + | @layer tokens | |
| 67 | + | ============================================================ */ | |
| 68 | + | @layer tokens { | |
| 69 | + | :root { | |
| 70 | + | /* Colors — light */ | |
| 71 | + | --color-bg: #ffffff; | |
| 72 | + | --color-bg-subtle: #f6f8fa; | |
| 73 | + | --color-bg-inset: #eef0f2; | |
| 74 | + | --color-border: #d0d7de; | |
| 75 | + | --color-border-muted: #e4e7eb; | |
| 76 | + | --color-text: #1f2328; | |
| 77 | + | --color-text-muted: #656d76; | |
| 78 | + | --color-link: #0969da; | |
| 79 | + | --color-link-hover: #0550ae; | |
| 80 | + | ||
| 81 | + | --color-accent: #fd7e14; | |
| 82 | + | --color-accent-bg: #fff3cd; | |
| 83 | + | ||
| 84 | + | --color-success: #1a7f37; | |
| 85 | + | --color-success-bg: #dafbe1; | |
| 86 | + | --color-danger: #cf222e; | |
| 87 | + | --color-danger-bg: #ffebe9; | |
| 88 | + | --color-warning: #9a6700; | |
| 89 | + | --color-warning-bg: #fff8c5; | |
| 90 | + | --color-merged: #8250df; | |
| 91 | + | --color-merged-bg: #fbefff; | |
| 92 | + | ||
| 93 | + | /* Typography */ | |
| 94 | + | --font-sans: | |
| 95 | + | system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| 96 | + | --font-mono: | |
| 97 | + | ui-monospace, "Cascadia Code", "Source Code Pro", monospace; | |
| 98 | + | ||
| 99 | + | --text-xs: 0.75rem; | |
| 100 | + | --text-sm: 0.875rem; | |
| 101 | + | --text-base: 1rem; | |
| 102 | + | --text-lg: 1.125rem; | |
| 103 | + | --text-xl: 1.25rem; | |
| 104 | + | --text-2xl: 1.5rem; | |
| 105 | + | --text-3xl: 1.875rem; | |
| 106 | + | ||
| 107 | + | /* Spacing */ | |
| 108 | + | --space-1: 0.25rem; | |
| 109 | + | --space-2: 0.5rem; | |
| 110 | + | --space-3: 0.75rem; | |
| 111 | + | --space-4: 1rem; | |
| 112 | + | --space-5: 1.25rem; | |
| 113 | + | --space-6: 1.5rem; | |
| 114 | + | --space-8: 2rem; | |
| 115 | + | --space-10: 2.5rem; | |
| 116 | + | --space-12: 3rem; | |
| 117 | + | --space-16: 4rem; | |
| 118 | + | ||
| 119 | + | /* Border radius */ | |
| 120 | + | --radius-sm: 4px; | |
| 121 | + | --radius-md: 6px; | |
| 122 | + | --radius-lg: 8px; | |
| 123 | + | --radius-full: 9999px; | |
| 124 | + | ||
| 125 | + | /* Shadows */ | |
| 126 | + | --shadow-sm: 0 1px 0 rgba(27, 31, 36, 0.04); | |
| 127 | + | --shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15); | |
| 128 | + | --shadow-lg: 0 8px 24px rgba(140, 149, 159, 0.2); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | @media (prefers-color-scheme: dark) { | |
| 132 | + | :root { | |
| 133 | + | --color-bg: #0d1117; | |
| 134 | + | --color-bg-subtle: #161b22; | |
| 135 | + | --color-bg-inset: #1c2128; | |
| 136 | + | --color-border: #30363d; | |
| 137 | + | --color-border-muted: #21262d; | |
| 138 | + | --color-text: #e6edf3; | |
| 139 | + | --color-text-muted: #7d8590; | |
| 140 | + | --color-link: #58a6ff; | |
| 141 | + | --color-link-hover: #79c0ff; | |
| 142 | + | ||
| 143 | + | --color-accent: #f0883e; | |
| 144 | + | --color-accent-bg: #3d2f00; | |
| 145 | + | ||
| 146 | + | --color-success: #3fb950; | |
| 147 | + | --color-success-bg: #0d2b19; | |
| 148 | + | --color-danger: #f85149; | |
| 149 | + | --color-danger-bg: #2d1419; | |
| 150 | + | --color-warning: #d29922; | |
| 151 | + | --color-warning-bg: #2c2000; | |
| 152 | + | --color-merged: #bc8cff; | |
| 153 | + | --color-merged-bg: #1e0b3e; | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /* Explicit theme overrides — applied via data-theme attribute set by cookie */ | |
| 158 | + | :root[data-theme="dark"] { | |
| 159 | + | --color-bg: #0d1117; | |
| 160 | + | --color-bg-subtle: #161b22; | |
| 161 | + | --color-bg-inset: #1c2128; | |
| 162 | + | --color-border: #30363d; | |
| 163 | + | --color-border-muted: #21262d; | |
| 164 | + | --color-text: #e6edf3; | |
| 165 | + | --color-text-muted: #7d8590; | |
| 166 | + | --color-link: #58a6ff; | |
| 167 | + | --color-link-hover: #79c0ff; | |
| 168 | + | --color-accent: #f0883e; | |
| 169 | + | --color-accent-bg: #3d2f00; | |
| 170 | + | --color-success: #3fb950; | |
| 171 | + | --color-success-bg: #0d2b19; | |
| 172 | + | --color-danger: #f85149; | |
| 173 | + | --color-danger-bg: #2d1419; | |
| 174 | + | --color-warning: #d29922; | |
| 175 | + | --color-warning-bg: #2c2000; | |
| 176 | + | --color-merged: #bc8cff; | |
| 177 | + | --color-merged-bg: #1e0b3e; | |
| 178 | + | } | |
| 179 | + | :root[data-theme="light"] { | |
| 180 | + | --color-bg: #ffffff; | |
| 181 | + | --color-bg-subtle: #f6f8fa; | |
| 182 | + | --color-bg-inset: #eef0f2; | |
| 183 | + | --color-border: #d0d7de; | |
| 184 | + | --color-border-muted: #e4e7eb; | |
| 185 | + | --color-text: #1f2328; | |
| 186 | + | --color-text-muted: #656d76; | |
| 187 | + | --color-link: #0969da; | |
| 188 | + | --color-link-hover: #0550ae; | |
| 189 | + | --color-accent: #fd7e14; | |
| 190 | + | --color-accent-bg: #fff3cd; | |
| 191 | + | --color-success: #1a7f37; | |
| 192 | + | --color-success-bg: #dafbe1; | |
| 193 | + | --color-danger: #cf222e; | |
| 194 | + | --color-danger-bg: #ffebe9; | |
| 195 | + | --color-warning: #9a6700; | |
| 196 | + | --color-warning-bg: #fff8c5; | |
| 197 | + | --color-merged: #8250df; | |
| 198 | + | --color-merged-bg: #fbefff; | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /* ============================================================ | |
| 203 | + | @layer layout | |
| 204 | + | ============================================================ */ | |
| 205 | + | @layer layout { | |
| 206 | + | body { | |
| 207 | + | font-family: var(--font-sans); | |
| 208 | + | font-size: var(--text-base); | |
| 209 | + | color: var(--color-text); | |
| 210 | + | background: var(--color-bg); | |
| 211 | + | display: grid; | |
| 212 | + | grid-template-rows: auto 1fr auto; | |
| 213 | + | min-height: 100dvh; | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | .site-header { | |
| 217 | + | background: var(--color-bg-subtle); | |
| 218 | + | border-bottom: 1px solid var(--color-border); | |
| 219 | + | position: sticky; | |
| 220 | + | top: 0; | |
| 221 | + | z-index: 100; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | .site-nav { | |
| 225 | + | max-width: 1200px; | |
| 226 | + | margin: 0 auto; | |
| 227 | + | padding: var(--space-3) var(--space-4); | |
| 228 | + | display: flex; | |
| 229 | + | align-items: center; | |
| 230 | + | gap: var(--space-4); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | .site-logo { | |
| 234 | + | display: flex; | |
| 235 | + | align-items: center; | |
| 236 | + | gap: var(--space-2); | |
| 237 | + | text-decoration: none; | |
| 238 | + | font-weight: 700; | |
| 239 | + | font-size: var(--text-lg); | |
| 240 | + | color: var(--color-text); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | .logo-icon { | |
| 244 | + | width: 1.6em; | |
| 245 | + | height: 1.6em; | |
| 246 | + | display: block; | |
| 247 | + | flex-shrink: 0; | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | .nav-links { | |
| 251 | + | margin-left: auto; | |
| 252 | + | display: flex; | |
| 253 | + | align-items: center; | |
| 254 | + | gap: var(--space-3); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | .site-main { | |
| 258 | + | padding: var(--space-8) var(--space-4); | |
| 259 | + | min-width: 0; | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | .site-footer { | |
| 263 | + | text-align: center; | |
| 264 | + | padding: var(--space-6) var(--space-4); | |
| 265 | + | font-size: var(--text-sm); | |
| 266 | + | color: var(--color-text-muted); | |
| 267 | + | border-top: 1px solid var(--color-border); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | .container { | |
| 271 | + | max-width: 1000px; | |
| 272 | + | margin: 0 auto; | |
| 273 | + | overflow-x: clip; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | .container-narrow { | |
| 277 | + | max-width: 680px; | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | .inline-form { | |
| 281 | + | display: inline; | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /* --- Two-step delete confirmation --- */ | |
| 285 | + | .confirm-details { | |
| 286 | + | position: relative; | |
| 287 | + | display: inline-block; | |
| 288 | + | } | |
| 289 | + | .confirm-details > summary { | |
| 290 | + | list-style: none; | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | .confirm-popup { | |
| 294 | + | position: absolute; | |
| 295 | + | right: 0; | |
| 296 | + | top: calc(100% + var(--space-1)); | |
| 297 | + | background: var(--color-bg); | |
| 298 | + | border: 1px solid var(--color-border); | |
| 299 | + | border-radius: var(--radius-md); | |
| 300 | + | padding: var(--space-3) var(--space-4); | |
| 301 | + | display: flex; | |
| 302 | + | align-items: center; | |
| 303 | + | gap: var(--space-3); | |
| 304 | + | white-space: nowrap; | |
| 305 | + | z-index: 100; | |
| 306 | + | font-size: var(--text-sm); | |
| 307 | + | color: var(--color-text-muted); | |
| 308 | + | } | |
| 309 | + | } | |
Asrc/styles/code.css
| @@ -0,0 +1,714 @@ | |||
|---|---|---|---|
| 1 | + | @layer components { | |
| 2 | + | /* --- File tree --- */ | |
| 3 | + | .file-tree { | |
| 4 | + | width: 100%; | |
| 5 | + | border: 1px solid var(--color-border); | |
| 6 | + | border-radius: var(--radius-lg); | |
| 7 | + | border-collapse: collapse; | |
| 8 | + | overflow: hidden; | |
| 9 | + | margin-bottom: var(--space-6); | |
| 10 | + | } | |
| 11 | + | .file-tree-row { | |
| 12 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 13 | + | } | |
| 14 | + | .file-tree-row:last-child { | |
| 15 | + | border-bottom: none; | |
| 16 | + | } | |
| 17 | + | .file-tree-row:hover { | |
| 18 | + | background: var(--color-bg-subtle); | |
| 19 | + | } | |
| 20 | + | .file-icon, | |
| 21 | + | .file-name, | |
| 22 | + | .file-size { | |
| 23 | + | padding: var(--space-2) var(--space-3); | |
| 24 | + | font-size: var(--text-sm); | |
| 25 | + | vertical-align: middle; | |
| 26 | + | } | |
| 27 | + | .file-icon { | |
| 28 | + | width: 36px; | |
| 29 | + | padding-right: var(--space-1); | |
| 30 | + | text-align: center; | |
| 31 | + | line-height: 0; | |
| 32 | + | } | |
| 33 | + | .file-name { | |
| 34 | + | width: 100%; | |
| 35 | + | } | |
| 36 | + | .file-name a { | |
| 37 | + | color: var(--color-link); | |
| 38 | + | text-decoration: none; | |
| 39 | + | } | |
| 40 | + | .file-name a:hover { | |
| 41 | + | text-decoration: underline; | |
| 42 | + | } | |
| 43 | + | .file-name a.file-name-dir { | |
| 44 | + | font-weight: 500; | |
| 45 | + | } | |
| 46 | + | .file-size { | |
| 47 | + | text-align: right; | |
| 48 | + | color: var(--color-text-muted); | |
| 49 | + | font-size: var(--text-xs); | |
| 50 | + | white-space: nowrap; | |
| 51 | + | padding-right: var(--space-4); | |
| 52 | + | } | |
| 53 | + | .file-icon-dir { | |
| 54 | + | color: var(--color-link); | |
| 55 | + | } | |
| 56 | + | .file-icon-file { | |
| 57 | + | color: var(--color-text-muted); | |
| 58 | + | } | |
| 59 | + | .tree-icon { | |
| 60 | + | display: inline-block; | |
| 61 | + | vertical-align: text-bottom; | |
| 62 | + | } | |
| 63 | + | .file-tree-row-up .file-name a { | |
| 64 | + | color: var(--color-text-muted); | |
| 65 | + | } | |
| 66 | + | .file-tree-row-up .file-name a:hover { | |
| 67 | + | color: var(--color-link); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /* --- File blob --- */ | |
| 71 | + | .file-blob-header { | |
| 72 | + | display: flex; | |
| 73 | + | align-items: center; | |
| 74 | + | justify-content: space-between; | |
| 75 | + | padding: var(--space-3) var(--space-4); | |
| 76 | + | background: var(--color-bg-subtle); | |
| 77 | + | border: 1px solid var(--color-border); | |
| 78 | + | border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| 79 | + | margin-top: var(--space-4); | |
| 80 | + | } | |
| 81 | + | .file-blob-name { | |
| 82 | + | font-size: var(--text-sm); | |
| 83 | + | font-weight: 500; | |
| 84 | + | } | |
| 85 | + | .file-blob-actions { | |
| 86 | + | display: flex; | |
| 87 | + | align-items: center; | |
| 88 | + | gap: var(--space-2); | |
| 89 | + | } | |
| 90 | + | .file-blob-body { | |
| 91 | + | border: 1px solid var(--color-border); | |
| 92 | + | border-top: none; | |
| 93 | + | border-radius: 0 0 var(--radius-lg) var(--radius-lg); | |
| 94 | + | overflow: auto; | |
| 95 | + | } | |
| 96 | + | .file-edit-textarea { | |
| 97 | + | display: block; | |
| 98 | + | width: 100%; | |
| 99 | + | box-sizing: border-box; | |
| 100 | + | font-family: var(--font-mono); | |
| 101 | + | font-size: var(--text-sm); | |
| 102 | + | line-height: 1.6; | |
| 103 | + | padding: var(--space-4); | |
| 104 | + | background: var(--color-bg); | |
| 105 | + | color: var(--color-text); | |
| 106 | + | border: none; | |
| 107 | + | resize: vertical; | |
| 108 | + | min-height: 400px; | |
| 109 | + | } | |
| 110 | + | .file-edit-textarea:focus { | |
| 111 | + | outline: 2px solid var(--color-accent); | |
| 112 | + | outline-offset: -2px; | |
| 113 | + | } | |
| 114 | + | /* Blob line-number table */ | |
| 115 | + | .blob-table { | |
| 116 | + | min-width: 100%; | |
| 117 | + | border-collapse: collapse; | |
| 118 | + | font-family: var(--font-mono); | |
| 119 | + | font-size: var(--text-sm); | |
| 120 | + | line-height: 1.6; | |
| 121 | + | background-color: var(--shiki-light-bg, #fff); | |
| 122 | + | color: var(--shiki-light, inherit); | |
| 123 | + | } | |
| 124 | + | .blob-ln { | |
| 125 | + | width: 1%; | |
| 126 | + | min-width: 48px; | |
| 127 | + | text-align: right; | |
| 128 | + | padding: 0 var(--space-3); | |
| 129 | + | color: var(--color-text-muted); | |
| 130 | + | border-right: 1px solid var(--color-border-muted); | |
| 131 | + | user-select: none; | |
| 132 | + | white-space: nowrap; | |
| 133 | + | vertical-align: top; | |
| 134 | + | } | |
| 135 | + | .blob-ln a { | |
| 136 | + | color: inherit; | |
| 137 | + | text-decoration: none; | |
| 138 | + | } | |
| 139 | + | .blob-ln a:hover { | |
| 140 | + | color: var(--color-link); | |
| 141 | + | } | |
| 142 | + | .blob-code { | |
| 143 | + | padding: 0 var(--space-4); | |
| 144 | + | white-space: pre; | |
| 145 | + | overflow: visible; | |
| 146 | + | vertical-align: top; | |
| 147 | + | } | |
| 148 | + | .blob-table tr:target { | |
| 149 | + | background: color-mix(in srgb, #ddf4ff 60%, transparent); | |
| 150 | + | } | |
| 151 | + | .blob-table tr:target .blob-ln { | |
| 152 | + | background: color-mix(in srgb, #ddf4ff 80%, transparent); | |
| 153 | + | } | |
| 154 | + | .file-download-notice { | |
| 155 | + | padding: var(--space-8); | |
| 156 | + | text-align: center; | |
| 157 | + | color: var(--color-text-muted); | |
| 158 | + | } | |
| 159 | + | .file-download-notice p { | |
| 160 | + | margin-bottom: var(--space-4); | |
| 161 | + | } | |
| 162 | + | .file-media { | |
| 163 | + | padding: var(--space-4); | |
| 164 | + | display: flex; | |
| 165 | + | justify-content: center; | |
| 166 | + | background: var(--color-bg-subtle); | |
| 167 | + | } | |
| 168 | + | .file-media-img { | |
| 169 | + | max-width: 100%; | |
| 170 | + | height: auto; | |
| 171 | + | display: block; | |
| 172 | + | } | |
| 173 | + | .file-media-audio { | |
| 174 | + | width: 100%; | |
| 175 | + | max-width: 600px; | |
| 176 | + | } | |
| 177 | + | .file-media-video { | |
| 178 | + | max-width: 100%; | |
| 179 | + | max-height: 80vh; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /* --- Commit list --- */ | |
| 183 | + | .commit-list { | |
| 184 | + | display: flex; | |
| 185 | + | flex-direction: column; | |
| 186 | + | } | |
| 187 | + | .commit-item { | |
| 188 | + | display: flex; | |
| 189 | + | flex-direction: column; | |
| 190 | + | padding: var(--space-3) 0; | |
| 191 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 192 | + | gap: var(--space-2); | |
| 193 | + | } | |
| 194 | + | .commit-item:last-child { | |
| 195 | + | border-bottom: none; | |
| 196 | + | } | |
| 197 | + | .commit-subject { | |
| 198 | + | color: var(--color-text); | |
| 199 | + | text-decoration: none; | |
| 200 | + | font-size: var(--text-sm); | |
| 201 | + | font-weight: 500; | |
| 202 | + | } | |
| 203 | + | .commit-subject:hover { | |
| 204 | + | color: var(--color-link); | |
| 205 | + | } | |
| 206 | + | .commit-meta { | |
| 207 | + | display: flex; | |
| 208 | + | align-items: center; | |
| 209 | + | justify-content: space-between; | |
| 210 | + | gap: var(--space-3); | |
| 211 | + | font-size: var(--text-xs); | |
| 212 | + | color: var(--color-text-muted); | |
| 213 | + | } | |
| 214 | + | .commit-meta-right { | |
| 215 | + | display: flex; | |
| 216 | + | align-items: center; | |
| 217 | + | gap: var(--space-3); | |
| 218 | + | white-space: nowrap; | |
| 219 | + | } | |
| 220 | + | .commit-hash { | |
| 221 | + | font-family: var(--font-mono); | |
| 222 | + | color: var(--color-link); | |
| 223 | + | text-decoration: none; | |
| 224 | + | background: var(--color-bg-inset); | |
| 225 | + | padding: 1px var(--space-2); | |
| 226 | + | border-radius: var(--radius-sm); | |
| 227 | + | } | |
| 228 | + | .commit-hash:hover { | |
| 229 | + | text-decoration: underline; | |
| 230 | + | } | |
| 231 | + | .mono { | |
| 232 | + | font-family: var(--font-mono); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /* --- Commit detail viewer --- */ | |
| 236 | + | .commit-page-top { | |
| 237 | + | display: flex; | |
| 238 | + | align-items: center; | |
| 239 | + | justify-content: space-between; | |
| 240 | + | margin-bottom: var(--space-4); | |
| 241 | + | } | |
| 242 | + | .commit-card { | |
| 243 | + | border: 1px solid var(--color-border); | |
| 244 | + | border-radius: var(--radius-lg); | |
| 245 | + | margin-bottom: var(--space-4); | |
| 246 | + | overflow: hidden; | |
| 247 | + | } | |
| 248 | + | .commit-card-subject { | |
| 249 | + | font-size: var(--text-xl); | |
| 250 | + | font-weight: 600; | |
| 251 | + | padding: var(--space-5) var(--space-6); | |
| 252 | + | border-bottom: 1px solid var(--color-border); | |
| 253 | + | line-height: 1.3; | |
| 254 | + | } | |
| 255 | + | .commit-card-subject-empty { | |
| 256 | + | color: var(--color-text-muted); | |
| 257 | + | font-style: italic; | |
| 258 | + | font-weight: 400; | |
| 259 | + | font-size: var(--text-sm); | |
| 260 | + | } | |
| 261 | + | .commit-card-body { | |
| 262 | + | padding: var(--space-4) var(--space-6); | |
| 263 | + | font-family: inherit; | |
| 264 | + | font-size: var(--text-sm); | |
| 265 | + | color: var(--color-text-muted); | |
| 266 | + | border-bottom: 1px solid var(--color-border); | |
| 267 | + | overflow-x: auto; | |
| 268 | + | } | |
| 269 | + | .commit-card-meta { | |
| 270 | + | padding: var(--space-4) var(--space-6); | |
| 271 | + | display: flex; | |
| 272 | + | flex-direction: column; | |
| 273 | + | gap: var(--space-2); | |
| 274 | + | background: var(--color-bg-subtle); | |
| 275 | + | } | |
| 276 | + | .commit-card-meta-row { | |
| 277 | + | display: flex; | |
| 278 | + | align-items: baseline; | |
| 279 | + | gap: var(--space-3); | |
| 280 | + | font-size: var(--text-sm); | |
| 281 | + | flex-wrap: wrap; | |
| 282 | + | } | |
| 283 | + | .commit-meta-label { | |
| 284 | + | font-weight: 600; | |
| 285 | + | color: var(--color-text-muted); | |
| 286 | + | min-width: 5rem; | |
| 287 | + | font-size: var(--text-xs); | |
| 288 | + | text-transform: uppercase; | |
| 289 | + | letter-spacing: 0.04em; | |
| 290 | + | } | |
| 291 | + | .commit-sha-full { | |
| 292 | + | font-size: var(--text-xs); | |
| 293 | + | background: var(--color-bg-inset); | |
| 294 | + | padding: 2px var(--space-2); | |
| 295 | + | border-radius: var(--radius-sm); | |
| 296 | + | word-break: break-all; | |
| 297 | + | } | |
| 298 | + | .commit-stats-bar { | |
| 299 | + | font-size: var(--text-sm); | |
| 300 | + | color: var(--color-text-muted); | |
| 301 | + | margin-bottom: var(--space-4); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /* --- Commit layout (sidebar + diffs) --- */ | |
| 305 | + | .commit-layout { | |
| 306 | + | display: grid; | |
| 307 | + | grid-template-columns: 1fr; | |
| 308 | + | gap: var(--space-5); | |
| 309 | + | align-items: start; | |
| 310 | + | } | |
| 311 | + | .commit-diffs { | |
| 312 | + | min-width: 0; | |
| 313 | + | } | |
| 314 | + | @media (min-width: 900px) { | |
| 315 | + | .commit-layout { | |
| 316 | + | grid-template-columns: 220px 1fr; | |
| 317 | + | } | |
| 318 | + | .commit-layout:has(.file-nav-details:not([open])) { | |
| 319 | + | grid-template-columns: auto 1fr; | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /* --- File nav sidebar --- */ | |
| 324 | + | .commit-file-nav { | |
| 325 | + | position: sticky; | |
| 326 | + | top: calc(56px + var(--space-4)); | |
| 327 | + | max-height: calc(100vh - 140px); | |
| 328 | + | overflow-y: auto; | |
| 329 | + | } | |
| 330 | + | .file-nav-details { | |
| 331 | + | border: 1px solid var(--color-border); | |
| 332 | + | border-radius: var(--radius-lg); | |
| 333 | + | background: var(--color-bg-subtle); | |
| 334 | + | overflow: hidden; | |
| 335 | + | } | |
| 336 | + | .file-nav-toggle { | |
| 337 | + | display: flex; | |
| 338 | + | align-items: center; | |
| 339 | + | gap: var(--space-2); | |
| 340 | + | padding: var(--space-3) var(--space-4); | |
| 341 | + | font-size: var(--text-sm); | |
| 342 | + | font-weight: 500; | |
| 343 | + | cursor: pointer; | |
| 344 | + | list-style: none; | |
| 345 | + | user-select: none; | |
| 346 | + | border-bottom: 1px solid var(--color-border); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | .file-nav-details:not([open]) .file-nav-toggle { | |
| 350 | + | border-bottom: none; | |
| 351 | + | } | |
| 352 | + | .file-nav-toggle-icon { | |
| 353 | + | font-size: 1em; | |
| 354 | + | transition: transform 0.15s; | |
| 355 | + | } | |
| 356 | + | .file-nav-details:not([open]) .file-nav-toggle-icon { | |
| 357 | + | transform: rotate(-90deg); | |
| 358 | + | } | |
| 359 | + | .file-nav-list { | |
| 360 | + | padding: var(--space-1) 0; | |
| 361 | + | list-style: none; | |
| 362 | + | } | |
| 363 | + | .file-nav-item { | |
| 364 | + | display: flex; | |
| 365 | + | align-items: center; | |
| 366 | + | gap: var(--space-2); | |
| 367 | + | padding: var(--space-1) var(--space-3); | |
| 368 | + | font-size: var(--text-xs); | |
| 369 | + | text-decoration: none; | |
| 370 | + | color: var(--color-text); | |
| 371 | + | border-radius: 0; | |
| 372 | + | overflow: hidden; | |
| 373 | + | } | |
| 374 | + | .file-nav-item:hover { | |
| 375 | + | background: var(--color-bg-inset); | |
| 376 | + | } | |
| 377 | + | .file-nav-status { | |
| 378 | + | flex-shrink: 0; | |
| 379 | + | width: 16px; | |
| 380 | + | height: 16px; | |
| 381 | + | display: inline-flex; | |
| 382 | + | align-items: center; | |
| 383 | + | justify-content: center; | |
| 384 | + | border-radius: var(--radius-sm); | |
| 385 | + | font-size: 9px; | |
| 386 | + | font-weight: 700; | |
| 387 | + | font-family: var(--font-mono); | |
| 388 | + | } | |
| 389 | + | .file-status-added { | |
| 390 | + | background: var(--color-success-bg); | |
| 391 | + | color: var(--color-success); | |
| 392 | + | } | |
| 393 | + | .file-status-deleted { | |
| 394 | + | background: var(--color-danger-bg); | |
| 395 | + | color: var(--color-danger); | |
| 396 | + | } | |
| 397 | + | .file-status-modified { | |
| 398 | + | background: var(--color-accent-bg); | |
| 399 | + | color: var(--color-accent); | |
| 400 | + | } | |
| 401 | + | .file-status-renamed { | |
| 402 | + | background: var(--color-merged-bg); | |
| 403 | + | color: var(--color-merged); | |
| 404 | + | } | |
| 405 | + | .file-status-copied { | |
| 406 | + | background: var(--color-merged-bg); | |
| 407 | + | color: var(--color-merged); | |
| 408 | + | } | |
| 409 | + | .file-nav-name { | |
| 410 | + | flex: 1; | |
| 411 | + | overflow: hidden; | |
| 412 | + | text-overflow: ellipsis; | |
| 413 | + | white-space: nowrap; | |
| 414 | + | font-family: var(--font-mono); | |
| 415 | + | } | |
| 416 | + | .file-nav-stat { | |
| 417 | + | flex-shrink: 0; | |
| 418 | + | display: flex; | |
| 419 | + | gap: var(--space-1); | |
| 420 | + | font-size: var(--text-xs); | |
| 421 | + | font-family: var(--font-mono); | |
| 422 | + | } | |
| 423 | + | .nav-add { | |
| 424 | + | color: var(--color-success); | |
| 425 | + | } | |
| 426 | + | .nav-del { | |
| 427 | + | color: var(--color-danger); | |
| 428 | + | } | |
| 429 | + | .nav-binary { | |
| 430 | + | color: var(--color-text-muted); | |
| 431 | + | font-size: var(--text-xs); | |
| 432 | + | } | |
| 433 | + | .file-nav-dir > details > .file-nav-list { | |
| 434 | + | padding-left: var(--space-4); | |
| 435 | + | } | |
| 436 | + | .file-nav-dir-toggle { | |
| 437 | + | display: flex; | |
| 438 | + | align-items: center; | |
| 439 | + | gap: var(--space-1); | |
| 440 | + | padding: var(--space-1) var(--space-3); | |
| 441 | + | font-size: var(--text-xs); | |
| 442 | + | font-family: var(--font-mono); | |
| 443 | + | color: var(--color-text-muted); | |
| 444 | + | cursor: pointer; | |
| 445 | + | list-style: none; | |
| 446 | + | user-select: none; | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | .file-nav-dir > details:not([open]) .file-nav-toggle-icon { | |
| 450 | + | transform: rotate(-90deg); | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | /* --- Diff file cards --- */ | |
| 454 | + | .diff-file { | |
| 455 | + | border: 1px solid var(--color-border); | |
| 456 | + | border-radius: var(--radius-lg); | |
| 457 | + | margin-bottom: var(--space-4); | |
| 458 | + | overflow: hidden; | |
| 459 | + | } | |
| 460 | + | .diff-file-header { | |
| 461 | + | display: flex; | |
| 462 | + | align-items: center; | |
| 463 | + | justify-content: space-between; | |
| 464 | + | gap: var(--space-3); | |
| 465 | + | padding: var(--space-3) var(--space-4); | |
| 466 | + | background: var(--color-bg-subtle); | |
| 467 | + | cursor: pointer; | |
| 468 | + | list-style: none; | |
| 469 | + | user-select: none; | |
| 470 | + | flex-wrap: wrap; | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | .diff-file-header-left { | |
| 474 | + | display: flex; | |
| 475 | + | align-items: center; | |
| 476 | + | gap: var(--space-2); | |
| 477 | + | min-width: 0; | |
| 478 | + | flex: 1; | |
| 479 | + | } | |
| 480 | + | .diff-file-header-right { | |
| 481 | + | display: flex; | |
| 482 | + | align-items: center; | |
| 483 | + | gap: var(--space-2); | |
| 484 | + | flex-shrink: 0; | |
| 485 | + | flex-wrap: wrap; | |
| 486 | + | } | |
| 487 | + | .diff-file-toggle-icon { | |
| 488 | + | font-size: 0.7em; | |
| 489 | + | flex-shrink: 0; | |
| 490 | + | transition: transform 0.15s; | |
| 491 | + | } | |
| 492 | + | .diff-file:not([open]) .diff-file-toggle-icon { | |
| 493 | + | transform: rotate(-90deg); | |
| 494 | + | } | |
| 495 | + | .diff-status-badge { | |
| 496 | + | flex-shrink: 0; | |
| 497 | + | width: 18px; | |
| 498 | + | height: 18px; | |
| 499 | + | display: inline-flex; | |
| 500 | + | align-items: center; | |
| 501 | + | justify-content: center; | |
| 502 | + | border-radius: var(--radius-sm); | |
| 503 | + | font-size: 10px; | |
| 504 | + | font-weight: 700; | |
| 505 | + | font-family: var(--font-mono); | |
| 506 | + | } | |
| 507 | + | .diff-status-added { | |
| 508 | + | background: var(--color-success-bg); | |
| 509 | + | color: var(--color-success); | |
| 510 | + | } | |
| 511 | + | .diff-status-deleted { | |
| 512 | + | background: var(--color-danger-bg); | |
| 513 | + | color: var(--color-danger); | |
| 514 | + | } | |
| 515 | + | .diff-status-modified { | |
| 516 | + | background: var(--color-accent-bg); | |
| 517 | + | color: var(--color-accent); | |
| 518 | + | } | |
| 519 | + | .diff-status-renamed { | |
| 520 | + | background: var(--color-merged-bg); | |
| 521 | + | color: var(--color-merged); | |
| 522 | + | } | |
| 523 | + | .diff-status-copied { | |
| 524 | + | background: var(--color-merged-bg); | |
| 525 | + | color: var(--color-merged); | |
| 526 | + | } | |
| 527 | + | .diff-file-path { | |
| 528 | + | font-size: var(--text-sm); | |
| 529 | + | font-weight: 500; | |
| 530 | + | overflow: hidden; | |
| 531 | + | text-overflow: ellipsis; | |
| 532 | + | white-space: nowrap; | |
| 533 | + | } | |
| 534 | + | .diff-rename-arrow { | |
| 535 | + | font-size: var(--text-xs); | |
| 536 | + | color: var(--color-text-muted); | |
| 537 | + | white-space: nowrap; | |
| 538 | + | overflow: hidden; | |
| 539 | + | text-overflow: ellipsis; | |
| 540 | + | } | |
| 541 | + | .diff-stat-add { | |
| 542 | + | color: var(--color-success); | |
| 543 | + | font-size: var(--text-sm); | |
| 544 | + | font-family: var(--font-mono); | |
| 545 | + | font-weight: 600; | |
| 546 | + | } | |
| 547 | + | .diff-stat-del { | |
| 548 | + | color: var(--color-danger); | |
| 549 | + | font-size: var(--text-sm); | |
| 550 | + | font-family: var(--font-mono); | |
| 551 | + | font-weight: 600; | |
| 552 | + | } | |
| 553 | + | .diff-stat-binary { | |
| 554 | + | color: var(--color-text-muted); | |
| 555 | + | font-size: var(--text-sm); | |
| 556 | + | font-family: var(--font-mono); | |
| 557 | + | } | |
| 558 | + | .btn-xs { | |
| 559 | + | padding: 1px var(--space-2); | |
| 560 | + | font-size: var(--text-xs); | |
| 561 | + | border: 1px solid var(--color-border); | |
| 562 | + | border-radius: var(--radius-sm); | |
| 563 | + | background: var(--color-bg); | |
| 564 | + | color: var(--color-text); | |
| 565 | + | text-decoration: none; | |
| 566 | + | cursor: pointer; | |
| 567 | + | white-space: nowrap; | |
| 568 | + | font-family: var(--font-mono); | |
| 569 | + | display: inline-flex; | |
| 570 | + | align-items: center; | |
| 571 | + | transition: background 0.1s; | |
| 572 | + | } | |
| 573 | + | .btn-xs:hover { | |
| 574 | + | background: var(--color-bg-inset); | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | /* --- Diff file body --- */ | |
| 578 | + | .diff-binary-notice { | |
| 579 | + | padding: var(--space-4) var(--space-6); | |
| 580 | + | font-size: var(--text-sm); | |
| 581 | + | color: var(--color-text-muted); | |
| 582 | + | font-style: italic; | |
| 583 | + | } | |
| 584 | + | .diff-hunk { | |
| 585 | + | border-top: 1px solid var(--color-border-muted); | |
| 586 | + | overflow-x: auto; | |
| 587 | + | } | |
| 588 | + | .diff-hunk:first-child { | |
| 589 | + | border-top: none; | |
| 590 | + | } | |
| 591 | + | .diff-hunk-header { | |
| 592 | + | padding: var(--space-1) var(--space-4); | |
| 593 | + | font-family: var(--font-mono); | |
| 594 | + | font-size: var(--text-xs); | |
| 595 | + | font-weight: normal; | |
| 596 | + | text-align: left; | |
| 597 | + | background: color-mix( | |
| 598 | + | in srgb, | |
| 599 | + | var(--color-merged-bg) 50%, | |
| 600 | + | var(--color-bg) | |
| 601 | + | ); | |
| 602 | + | color: var(--color-merged); | |
| 603 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 604 | + | white-space: pre; | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | /* --- Diff table --- */ | |
| 608 | + | .diff-table { | |
| 609 | + | min-width: 100%; | |
| 610 | + | border-collapse: collapse; | |
| 611 | + | font-family: var(--font-mono); | |
| 612 | + | font-size: var(--text-xs); | |
| 613 | + | line-height: 1.5; | |
| 614 | + | } | |
| 615 | + | .diff-ln { | |
| 616 | + | width: 44px; | |
| 617 | + | min-width: 44px; | |
| 618 | + | text-align: right; | |
| 619 | + | padding: 0 var(--space-2); | |
| 620 | + | color: var(--color-text-muted); | |
| 621 | + | border-right: 1px solid var(--color-border-muted); | |
| 622 | + | user-select: none; | |
| 623 | + | vertical-align: top; | |
| 624 | + | white-space: nowrap; | |
| 625 | + | } | |
| 626 | + | .diff-sign { | |
| 627 | + | width: 18px; | |
| 628 | + | min-width: 18px; | |
| 629 | + | text-align: center; | |
| 630 | + | padding: 0 var(--space-1); | |
| 631 | + | user-select: none; | |
| 632 | + | vertical-align: top; | |
| 633 | + | } | |
| 634 | + | .diff-code { | |
| 635 | + | padding: 0 var(--space-3); | |
| 636 | + | white-space: pre; | |
| 637 | + | overflow: visible; | |
| 638 | + | vertical-align: top; | |
| 639 | + | word-break: normal; | |
| 640 | + | } | |
| 641 | + | /* Row type backgrounds */ | |
| 642 | + | .diff-row-add { | |
| 643 | + | background: color-mix( | |
| 644 | + | in srgb, | |
| 645 | + | var(--color-success-bg) 70%, | |
| 646 | + | transparent | |
| 647 | + | ); | |
| 648 | + | } | |
| 649 | + | .diff-row-del { | |
| 650 | + | background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent); | |
| 651 | + | } | |
| 652 | + | .diff-row-add .diff-ln, | |
| 653 | + | .diff-row-add .diff-sign { | |
| 654 | + | background: color-mix( | |
| 655 | + | in srgb, | |
| 656 | + | var(--color-success-bg) 90%, | |
| 657 | + | transparent | |
| 658 | + | ); | |
| 659 | + | } | |
| 660 | + | .diff-row-del .diff-ln, | |
| 661 | + | .diff-row-del .diff-sign { | |
| 662 | + | background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent); | |
| 663 | + | } | |
| 664 | + | .diff-row-add .diff-sign { | |
| 665 | + | color: var(--color-success); | |
| 666 | + | font-weight: 700; | |
| 667 | + | } | |
| 668 | + | .diff-row-del .diff-sign { | |
| 669 | + | color: var(--color-danger); | |
| 670 | + | font-weight: 700; | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | .diff-ctrl { | |
| 674 | + | color: #f0f0f0; | |
| 675 | + | background: #c0392b; | |
| 676 | + | border-radius: 2px; | |
| 677 | + | font-weight: bold; | |
| 678 | + | padding: 0 1px; | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | /* Shiki dark-mode support in diff code cells */ | |
| 682 | + | @media (prefers-color-scheme: dark) { | |
| 683 | + | .diff-code span[style] { | |
| 684 | + | /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */ | |
| 685 | + | color: var(--shiki-dark) !important; | |
| 686 | + | } | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | /* Shiki dark-mode support in file blob view */ | |
| 690 | + | @media (prefers-color-scheme: dark) { | |
| 691 | + | .blob-table { | |
| 692 | + | background-color: var(--shiki-dark-bg); | |
| 693 | + | color: var(--shiki-dark); | |
| 694 | + | } | |
| 695 | + | .blob-table span[style] { | |
| 696 | + | /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */ | |
| 697 | + | color: var(--shiki-dark) !important; | |
| 698 | + | } | |
| 699 | + | .blob-table tr:target { | |
| 700 | + | background: color-mix(in srgb, #1c3a52 60%, transparent); | |
| 701 | + | } | |
| 702 | + | .blob-table tr:target .blob-ln { | |
| 703 | + | background: color-mix(in srgb, #1c3a52 80%, transparent); | |
| 704 | + | } | |
| 705 | + | } | |
| 706 | + | :root[data-theme="dark"] .blob-table { | |
| 707 | + | background-color: var(--shiki-dark-bg); | |
| 708 | + | color: var(--shiki-dark); | |
| 709 | + | } | |
| 710 | + | :root[data-theme="dark"] .blob-table span[style] { | |
| 711 | + | /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */ | |
| 712 | + | color: var(--shiki-dark) !important; | |
| 713 | + | } | |
| 714 | + | } | |
Asrc/styles/components.css
| @@ -0,0 +1,1798 @@ | |||
|---|---|---|---|
| 1 | + | @layer components { | |
| 2 | + | /* --- Buttons --- */ | |
| 3 | + | .btn { | |
| 4 | + | display: inline-flex; | |
| 5 | + | align-items: center; | |
| 6 | + | gap: var(--space-2); | |
| 7 | + | padding: var(--space-2) var(--space-4); | |
| 8 | + | border: 1px solid var(--color-border); | |
| 9 | + | border-radius: var(--radius-md); | |
| 10 | + | font-size: var(--text-sm); | |
| 11 | + | font-weight: 500; | |
| 12 | + | text-decoration: none; | |
| 13 | + | cursor: pointer; | |
| 14 | + | background: var(--color-bg-subtle); | |
| 15 | + | color: var(--color-text); | |
| 16 | + | transition: | |
| 17 | + | background 0.1s, | |
| 18 | + | border-color 0.1s; | |
| 19 | + | white-space: nowrap; | |
| 20 | + | } | |
| 21 | + | .btn:hover { | |
| 22 | + | background: var(--color-bg-inset); | |
| 23 | + | border-color: var(--color-border); | |
| 24 | + | } | |
| 25 | + | .btn:disabled { | |
| 26 | + | opacity: 0.5; | |
| 27 | + | cursor: not-allowed; | |
| 28 | + | } | |
| 29 | + | .btn-primary { | |
| 30 | + | background: var(--color-accent); | |
| 31 | + | border-color: transparent; | |
| 32 | + | color: #fff; | |
| 33 | + | } | |
| 34 | + | .btn-primary:hover { | |
| 35 | + | background: color-mix(in srgb, var(--color-accent) 82%, #000); | |
| 36 | + | } | |
| 37 | + | .btn-secondary { | |
| 38 | + | background: var(--color-bg-subtle); | |
| 39 | + | border-color: var(--color-border); | |
| 40 | + | } | |
| 41 | + | .btn-sm { | |
| 42 | + | padding: var(--space-1) var(--space-3); | |
| 43 | + | font-size: var(--text-xs); | |
| 44 | + | } | |
| 45 | + | .btn-block { | |
| 46 | + | width: 100%; | |
| 47 | + | justify-content: center; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /* --- Forms --- */ | |
| 51 | + | .form-group { | |
| 52 | + | display: flex; | |
| 53 | + | flex-direction: column; | |
| 54 | + | gap: var(--space-2); | |
| 55 | + | margin-bottom: var(--space-4); | |
| 56 | + | } | |
| 57 | + | .form-group label, | |
| 58 | + | .form-group .form-group-label { | |
| 59 | + | font-size: var(--text-sm); | |
| 60 | + | font-weight: 500; | |
| 61 | + | color: var(--color-text); | |
| 62 | + | } | |
| 63 | + | .form-group input:not([type="file"]):not([type="checkbox"]), | |
| 64 | + | .form-group textarea, | |
| 65 | + | .form-group select { | |
| 66 | + | padding: var(--space-2) var(--space-3); | |
| 67 | + | border: 1px solid var(--color-border); | |
| 68 | + | border-radius: var(--radius-md); | |
| 69 | + | background: var(--color-bg); | |
| 70 | + | color: var(--color-text); | |
| 71 | + | font-size: var(--text-sm); | |
| 72 | + | } | |
| 73 | + | .form-group input:not([type="file"]):not([type="checkbox"]):focus, | |
| 74 | + | .form-group textarea:focus { | |
| 75 | + | outline: 2px solid var(--color-accent); | |
| 76 | + | outline-offset: -1px; | |
| 77 | + | border-color: var(--color-accent); | |
| 78 | + | } | |
| 79 | + | .form-group input[type="file"] { | |
| 80 | + | font-size: var(--text-sm); | |
| 81 | + | color: var(--color-text); | |
| 82 | + | padding: 0; | |
| 83 | + | border: none; | |
| 84 | + | background: none; | |
| 85 | + | } | |
| 86 | + | .form-group textarea { | |
| 87 | + | resize: vertical; | |
| 88 | + | min-height: 120px; | |
| 89 | + | } | |
| 90 | + | .form-success { | |
| 91 | + | background: var(--color-success-bg); | |
| 92 | + | color: var(--color-success); | |
| 93 | + | border: 1px solid var(--color-success); | |
| 94 | + | border-radius: var(--radius-md); | |
| 95 | + | padding: var(--space-3) var(--space-4); | |
| 96 | + | font-size: var(--text-sm); | |
| 97 | + | margin-bottom: var(--space-4); | |
| 98 | + | } | |
| 99 | + | .form-hint { | |
| 100 | + | font-size: var(--text-sm); | |
| 101 | + | color: var(--color-text-muted); | |
| 102 | + | margin: 0; | |
| 103 | + | } | |
| 104 | + | .form-error { | |
| 105 | + | background: var(--color-danger-bg); | |
| 106 | + | color: var(--color-danger); | |
| 107 | + | border: 1px solid var(--color-danger); | |
| 108 | + | border-radius: var(--radius-md); | |
| 109 | + | padding: var(--space-3) var(--space-4); | |
| 110 | + | font-size: var(--text-sm); | |
| 111 | + | margin-bottom: var(--space-4); | |
| 112 | + | } | |
| 113 | + | .form-actions { | |
| 114 | + | display: flex; | |
| 115 | + | gap: var(--space-3); | |
| 116 | + | align-items: center; | |
| 117 | + | flex-wrap: wrap; | |
| 118 | + | } | |
| 119 | + | .form-card { | |
| 120 | + | background: var(--color-bg-subtle); | |
| 121 | + | border: 1px solid var(--color-border); | |
| 122 | + | border-radius: var(--radius-lg); | |
| 123 | + | padding: var(--space-6); | |
| 124 | + | margin-top: var(--space-4); | |
| 125 | + | } | |
| 126 | + | .checkbox-label { | |
| 127 | + | display: flex; | |
| 128 | + | align-items: center; | |
| 129 | + | gap: var(--space-2); | |
| 130 | + | cursor: pointer; | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /* --- Page header --- */ | |
| 134 | + | .page-header { | |
| 135 | + | display: flex; | |
| 136 | + | align-items: center; | |
| 137 | + | justify-content: space-between; | |
| 138 | + | margin-bottom: var(--space-6); | |
| 139 | + | gap: var(--space-4); | |
| 140 | + | } | |
| 141 | + | .page-title { | |
| 142 | + | font-size: var(--text-2xl); | |
| 143 | + | color: var(--color-text); | |
| 144 | + | margin-bottom: var(--space-4); | |
| 145 | + | } | |
| 146 | + | .page-title a { | |
| 147 | + | text-decoration: none; | |
| 148 | + | color: var(--color-link); | |
| 149 | + | } | |
| 150 | + | .page-title a:hover { | |
| 151 | + | text-decoration: underline; | |
| 152 | + | } | |
| 153 | + | .section-title { | |
| 154 | + | font-size: var(--text-lg); | |
| 155 | + | margin-bottom: var(--space-4); | |
| 156 | + | padding-bottom: var(--space-2); | |
| 157 | + | border-bottom: 1px solid var(--color-border); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /* --- Auth --- */ | |
| 161 | + | .auth-container { | |
| 162 | + | max-width: 400px; | |
| 163 | + | margin: var(--space-12) auto; | |
| 164 | + | } | |
| 165 | + | .auth-container .page-title { | |
| 166 | + | margin-bottom: var(--space-6); | |
| 167 | + | } | |
| 168 | + | .auth-form { | |
| 169 | + | margin-bottom: var(--space-4); | |
| 170 | + | } | |
| 171 | + | .auth-footer { | |
| 172 | + | text-align: center; | |
| 173 | + | font-size: var(--text-sm); | |
| 174 | + | color: var(--color-text-muted); | |
| 175 | + | margin-top: var(--space-4); | |
| 176 | + | } | |
| 177 | + | .passkey-btn { | |
| 178 | + | display: none; | |
| 179 | + | margin-top: var(--space-3); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /* --- Repo header --- */ | |
| 183 | + | .repo-header { | |
| 184 | + | margin-bottom: var(--space-2); | |
| 185 | + | } | |
| 186 | + | .repo-title-row { | |
| 187 | + | display: flex; | |
| 188 | + | align-items: center; | |
| 189 | + | gap: var(--space-3); | |
| 190 | + | flex-wrap: wrap; | |
| 191 | + | } | |
| 192 | + | .repo-title-row .page-title { | |
| 193 | + | margin-bottom: 0; | |
| 194 | + | } | |
| 195 | + | .repo-header-title { | |
| 196 | + | display: flex; | |
| 197 | + | align-items: center; | |
| 198 | + | gap: var(--space-3); | |
| 199 | + | flex-wrap: wrap; | |
| 200 | + | } | |
| 201 | + | .repo-description { | |
| 202 | + | color: var(--color-text-muted); | |
| 203 | + | margin-top: var(--space-2); | |
| 204 | + | } | |
| 205 | + | .repo-header-meta { | |
| 206 | + | display: flex; | |
| 207 | + | align-items: center; | |
| 208 | + | gap: var(--space-4); | |
| 209 | + | margin-bottom: var(--space-3); | |
| 210 | + | font-size: var(--text-sm); | |
| 211 | + | color: var(--color-text-muted); | |
| 212 | + | } | |
| 213 | + | .icon { | |
| 214 | + | font-style: normal; | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /* --- Branch selector --- */ | |
| 218 | + | .branch-selector { | |
| 219 | + | display: inline-flex; | |
| 220 | + | align-items: center; | |
| 221 | + | gap: var(--space-2); | |
| 222 | + | } | |
| 223 | + | .branch-selector-icon { | |
| 224 | + | color: var(--color-text-muted); | |
| 225 | + | font-size: var(--text-sm); | |
| 226 | + | user-select: none; | |
| 227 | + | } | |
| 228 | + | .branch-select { | |
| 229 | + | padding: var(--space-1) var(--space-2); | |
| 230 | + | padding-right: var(--space-5); | |
| 231 | + | border: 1px solid var(--color-border); | |
| 232 | + | border-radius: var(--radius-md); | |
| 233 | + | background: var(--color-bg-subtle); | |
| 234 | + | font-size: var(--text-sm); | |
| 235 | + | font-family: var(--font-mono); | |
| 236 | + | color: var(--color-text); | |
| 237 | + | cursor: pointer; | |
| 238 | + | appearance: auto; | |
| 239 | + | max-width: 180px; | |
| 240 | + | } | |
| 241 | + | .branch-select:hover { | |
| 242 | + | border-color: var(--color-text-muted); | |
| 243 | + | } | |
| 244 | + | .branch-select:focus { | |
| 245 | + | outline: 2px solid var(--color-accent); | |
| 246 | + | outline-offset: -1px; | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | /* toolbar row above file tree */ | |
| 250 | + | .tree-toolbar { | |
| 251 | + | display: flex; | |
| 252 | + | align-items: center; | |
| 253 | + | justify-content: space-between; | |
| 254 | + | margin-bottom: var(--space-3); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /* commits-header flex row */ | |
| 258 | + | .commits-header { | |
| 259 | + | display: flex; | |
| 260 | + | align-items: center; | |
| 261 | + | gap: var(--space-4); | |
| 262 | + | margin-bottom: var(--space-4); | |
| 263 | + | flex-wrap: wrap; | |
| 264 | + | } | |
| 265 | + | .commits-header .section-title { | |
| 266 | + | margin-bottom: 0; | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /* --- Repo nav bar (tabs + private badge) --- */ | |
| 270 | + | .repo-nav-bar { | |
| 271 | + | display: flex; | |
| 272 | + | align-items: center; | |
| 273 | + | justify-content: space-between; | |
| 274 | + | border-bottom: 1px solid var(--color-border); | |
| 275 | + | margin-bottom: var(--space-6); | |
| 276 | + | gap: var(--space-4); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /* --- Repo tabs --- */ | |
| 280 | + | .repo-tabs { | |
| 281 | + | display: flex; | |
| 282 | + | gap: 0; | |
| 283 | + | flex: 1; | |
| 284 | + | overflow-x: auto; | |
| 285 | + | overflow-y: hidden; | |
| 286 | + | } | |
| 287 | + | .repo-tab { | |
| 288 | + | padding: var(--space-3) var(--space-4); | |
| 289 | + | font-size: var(--text-sm); | |
| 290 | + | text-decoration: none; | |
| 291 | + | color: var(--color-text-muted); | |
| 292 | + | border-bottom: 2px solid transparent; | |
| 293 | + | margin-bottom: -1px; | |
| 294 | + | transition: color 0.1s; | |
| 295 | + | } | |
| 296 | + | .repo-tab:hover { | |
| 297 | + | color: var(--color-text); | |
| 298 | + | } | |
| 299 | + | .repo-tab.active { | |
| 300 | + | color: var(--color-text); | |
| 301 | + | font-weight: 600; | |
| 302 | + | border-bottom-color: var(--color-accent); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /* --- Repo list --- */ | |
| 306 | + | .repo-list { | |
| 307 | + | display: flex; | |
| 308 | + | flex-direction: column; | |
| 309 | + | gap: var(--space-2); | |
| 310 | + | } | |
| 311 | + | .repo-card { | |
| 312 | + | display: flex; | |
| 313 | + | align-items: flex-start; | |
| 314 | + | justify-content: space-between; | |
| 315 | + | padding: var(--space-4) var(--space-5); | |
| 316 | + | border: 1px solid var(--color-border); | |
| 317 | + | border-radius: var(--radius-lg); | |
| 318 | + | background: var(--color-bg); | |
| 319 | + | gap: var(--space-4); | |
| 320 | + | transition: border-color 0.15s; | |
| 321 | + | } | |
| 322 | + | .repo-card:hover { | |
| 323 | + | border-color: var(--color-accent); | |
| 324 | + | } | |
| 325 | + | .repo-card-main { | |
| 326 | + | flex: 1; | |
| 327 | + | min-width: 0; | |
| 328 | + | } | |
| 329 | + | .repo-card-title { | |
| 330 | + | display: flex; | |
| 331 | + | align-items: center; | |
| 332 | + | gap: var(--space-2); | |
| 333 | + | margin-bottom: var(--space-1); | |
| 334 | + | } | |
| 335 | + | .repo-name { | |
| 336 | + | font-size: var(--text-lg); | |
| 337 | + | font-weight: 600; | |
| 338 | + | color: var(--color-link); | |
| 339 | + | text-decoration: none; | |
| 340 | + | word-break: break-all; | |
| 341 | + | } | |
| 342 | + | .repo-name:hover { | |
| 343 | + | text-decoration: underline; | |
| 344 | + | } | |
| 345 | + | .repo-card-meta { | |
| 346 | + | display: flex; | |
| 347 | + | flex-direction: column; | |
| 348 | + | align-items: flex-end; | |
| 349 | + | gap: var(--space-1); | |
| 350 | + | font-size: var(--text-xs); | |
| 351 | + | color: var(--color-text-muted); | |
| 352 | + | white-space: nowrap; | |
| 353 | + | } | |
| 354 | + | .repo-date { | |
| 355 | + | color: var(--color-text-muted); | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | /* --- Badges --- */ | |
| 359 | + | .badge { | |
| 360 | + | display: inline-flex; | |
| 361 | + | align-items: center; | |
| 362 | + | padding: 1px var(--space-2); | |
| 363 | + | border-radius: var(--radius-full); | |
| 364 | + | font-size: var(--text-xs); | |
| 365 | + | font-weight: 500; | |
| 366 | + | border: 1px solid; | |
| 367 | + | text-decoration: none; | |
| 368 | + | } | |
| 369 | + | .badge-private { | |
| 370 | + | color: var(--color-warning); | |
| 371 | + | border-color: var(--color-warning); | |
| 372 | + | background: var(--color-warning-bg); | |
| 373 | + | } | |
| 374 | + | .badge-pinned { | |
| 375 | + | color: var(--color-accent); | |
| 376 | + | border-color: var(--color-accent); | |
| 377 | + | background: var(--color-accent-bg); | |
| 378 | + | } | |
| 379 | + | .issue-badge, | |
| 380 | + | .patch-badge { | |
| 381 | + | display: inline-flex; | |
| 382 | + | align-items: center; | |
| 383 | + | padding: var(--space-1) var(--space-3); | |
| 384 | + | border-radius: var(--radius-full); | |
| 385 | + | font-size: var(--text-xs); | |
| 386 | + | font-weight: 500; | |
| 387 | + | text-transform: capitalize; | |
| 388 | + | } | |
| 389 | + | .issue-badge.open, | |
| 390 | + | .patch-badge.open { | |
| 391 | + | background: var(--color-success-bg); | |
| 392 | + | color: var(--color-success); | |
| 393 | + | } | |
| 394 | + | .issue-badge.closed, | |
| 395 | + | .patch-badge.closed { | |
| 396 | + | background: var(--color-danger-bg); | |
| 397 | + | color: var(--color-danger); | |
| 398 | + | } | |
| 399 | + | .issue-badge.completed { | |
| 400 | + | background: var(--color-merged-bg); | |
| 401 | + | color: var(--color-merged); | |
| 402 | + | } | |
| 403 | + | .patch-badge.merged { | |
| 404 | + | background: var(--color-merged-bg); | |
| 405 | + | color: var(--color-merged); | |
| 406 | + | } | |
| 407 | + | .sig-badge { | |
| 408 | + | display: inline-flex; | |
| 409 | + | align-items: center; | |
| 410 | + | padding: var(--space-1) var(--space-3); | |
| 411 | + | border-radius: var(--radius-full); | |
| 412 | + | font-size: var(--text-xs); | |
| 413 | + | font-weight: 500; | |
| 414 | + | } | |
| 415 | + | .sig-badge.verified { | |
| 416 | + | background: var(--color-success-bg); | |
| 417 | + | color: var(--color-success); | |
| 418 | + | } | |
| 419 | + | .sig-badge.unverified { | |
| 420 | + | background: var(--color-danger-bg); | |
| 421 | + | color: var(--color-danger); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | /* --- Empty state --- */ | |
| 425 | + | .empty-state { | |
| 426 | + | text-align: center; | |
| 427 | + | padding: var(--space-16) var(--space-4); | |
| 428 | + | color: var(--color-text-muted); | |
| 429 | + | } | |
| 430 | + | .empty-state p { | |
| 431 | + | margin-bottom: var(--space-4); | |
| 432 | + | font-size: var(--text-lg); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | /* --- Breadcrumb --- */ | |
| 436 | + | .breadcrumb { | |
| 437 | + | display: flex; | |
| 438 | + | align-items: center; | |
| 439 | + | flex-wrap: wrap; | |
| 440 | + | gap: var(--space-1); | |
| 441 | + | font-size: var(--text-sm); | |
| 442 | + | margin-bottom: var(--space-4); | |
| 443 | + | } | |
| 444 | + | .breadcrumb a { | |
| 445 | + | color: var(--color-link); | |
| 446 | + | text-decoration: none; | |
| 447 | + | } | |
| 448 | + | .breadcrumb a:hover { | |
| 449 | + | text-decoration: underline; | |
| 450 | + | } | |
| 451 | + | .breadcrumb-sep { | |
| 452 | + | color: var(--color-text-muted); | |
| 453 | + | } | |
| 454 | + | .breadcrumb-current { | |
| 455 | + | color: var(--color-text-muted); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | /* --- Issue / Patch list --- */ | |
| 459 | + | .list-header { | |
| 460 | + | display: flex; | |
| 461 | + | align-items: center; | |
| 462 | + | justify-content: space-between; | |
| 463 | + | padding: var(--space-3) var(--space-4); | |
| 464 | + | background: var(--color-bg-subtle); | |
| 465 | + | border: 1px solid var(--color-border); | |
| 466 | + | border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| 467 | + | gap: var(--space-4); | |
| 468 | + | } | |
| 469 | + | .list-header-tabs { | |
| 470 | + | display: flex; | |
| 471 | + | gap: var(--space-2); | |
| 472 | + | } | |
| 473 | + | .list-tab { | |
| 474 | + | padding: var(--space-1) var(--space-3); | |
| 475 | + | border-radius: var(--radius-md); | |
| 476 | + | font-size: var(--text-sm); | |
| 477 | + | text-decoration: none; | |
| 478 | + | color: var(--color-text-muted); | |
| 479 | + | } | |
| 480 | + | .list-tab:hover { | |
| 481 | + | background: var(--color-bg-inset); | |
| 482 | + | color: var(--color-text); | |
| 483 | + | } | |
| 484 | + | .list-tab.active { | |
| 485 | + | background: var(--color-bg-inset); | |
| 486 | + | color: var(--color-text); | |
| 487 | + | font-weight: 600; | |
| 488 | + | } | |
| 489 | + | .issue-list { | |
| 490 | + | border: 1px solid var(--color-border); | |
| 491 | + | border-top: none; | |
| 492 | + | border-radius: 0 0 var(--radius-lg) var(--radius-lg); | |
| 493 | + | } | |
| 494 | + | .issue-item { | |
| 495 | + | padding: var(--space-4) var(--space-4); | |
| 496 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 497 | + | } | |
| 498 | + | .issue-item:last-child { | |
| 499 | + | border-bottom: none; | |
| 500 | + | } | |
| 501 | + | .issue-main { | |
| 502 | + | display: flex; | |
| 503 | + | align-items: center; | |
| 504 | + | gap: var(--space-2); | |
| 505 | + | margin-bottom: var(--space-1); | |
| 506 | + | flex-wrap: wrap; | |
| 507 | + | } | |
| 508 | + | .issue-title { | |
| 509 | + | font-weight: 500; | |
| 510 | + | text-decoration: none; | |
| 511 | + | color: var(--color-text); | |
| 512 | + | flex: 1; | |
| 513 | + | } | |
| 514 | + | .issue-title:hover { | |
| 515 | + | color: var(--color-link); | |
| 516 | + | } | |
| 517 | + | .issue-meta { | |
| 518 | + | font-size: var(--text-xs); | |
| 519 | + | color: var(--color-text-muted); | |
| 520 | + | display: flex; | |
| 521 | + | gap: var(--space-3); | |
| 522 | + | } | |
| 523 | + | .issue-status-dot, | |
| 524 | + | .patch-status-dot { | |
| 525 | + | width: 8px; | |
| 526 | + | height: 8px; | |
| 527 | + | border-radius: 50%; | |
| 528 | + | flex-shrink: 0; | |
| 529 | + | } | |
| 530 | + | .issue-status-dot.open, | |
| 531 | + | .patch-status-dot.open { | |
| 532 | + | background: var(--color-success); | |
| 533 | + | } | |
| 534 | + | .issue-status-dot.closed, | |
| 535 | + | .patch-status-dot.closed { | |
| 536 | + | background: var(--color-danger); | |
| 537 | + | } | |
| 538 | + | .issue-status-dot.completed { | |
| 539 | + | background: var(--color-merged); | |
| 540 | + | } | |
| 541 | + | .patch-status-dot.merged { | |
| 542 | + | background: var(--color-merged); | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | /* --- Issue detail / Timeline --- */ | |
| 546 | + | .issue-detail-header { | |
| 547 | + | display: flex; | |
| 548 | + | align-items: center; | |
| 549 | + | flex-wrap: wrap; | |
| 550 | + | gap: var(--space-3); | |
| 551 | + | margin-bottom: var(--space-4); | |
| 552 | + | } | |
| 553 | + | .issue-number { | |
| 554 | + | font-size: var(--text-2xl); | |
| 555 | + | font-weight: 600; | |
| 556 | + | color: var(--color-text-muted); | |
| 557 | + | white-space: nowrap; | |
| 558 | + | } | |
| 559 | + | .issue-detail-title { | |
| 560 | + | font-size: var(--text-2xl); | |
| 561 | + | font-weight: 600; | |
| 562 | + | overflow-wrap: anywhere; | |
| 563 | + | } | |
| 564 | + | .issue-detail-meta-actions { | |
| 565 | + | margin-left: auto; | |
| 566 | + | display: flex; | |
| 567 | + | gap: var(--space-2); | |
| 568 | + | flex-shrink: 0; | |
| 569 | + | } | |
| 570 | + | .patch-author-meta { | |
| 571 | + | display: flex; | |
| 572 | + | align-items: center; | |
| 573 | + | gap: var(--space-2); | |
| 574 | + | font-size: var(--text-xs); | |
| 575 | + | margin-top: calc(-1 * var(--space-4)); | |
| 576 | + | margin-bottom: var(--space-6); | |
| 577 | + | } | |
| 578 | + | .patch-author-label { | |
| 579 | + | color: var(--color-text-muted); | |
| 580 | + | } | |
| 581 | + | .patch-author-identity { | |
| 582 | + | font-family: monospace; | |
| 583 | + | } | |
| 584 | + | .timeline-item { | |
| 585 | + | border: 1px solid var(--color-border); | |
| 586 | + | border-radius: var(--radius-lg); | |
| 587 | + | margin-bottom: var(--space-4); | |
| 588 | + | overflow: hidden; | |
| 589 | + | } | |
| 590 | + | .timeline-author { | |
| 591 | + | background: var(--color-bg-subtle); | |
| 592 | + | padding: var(--space-3) var(--space-4); | |
| 593 | + | font-size: var(--text-sm); | |
| 594 | + | display: flex; | |
| 595 | + | align-items: center; | |
| 596 | + | justify-content: space-between; | |
| 597 | + | gap: var(--space-3); | |
| 598 | + | border-bottom: 1px solid var(--color-border); | |
| 599 | + | overflow-wrap: anywhere; | |
| 600 | + | } | |
| 601 | + | .timeline-author time { | |
| 602 | + | color: var(--color-text-muted); | |
| 603 | + | font-size: var(--text-xs); | |
| 604 | + | width: max-content; | |
| 605 | + | } | |
| 606 | + | .timeline-author-right { | |
| 607 | + | margin-left: auto; | |
| 608 | + | display: flex; | |
| 609 | + | align-items: center; | |
| 610 | + | gap: var(--space-2); | |
| 611 | + | } | |
| 612 | + | .edited-indicator { | |
| 613 | + | color: var(--color-text-muted); | |
| 614 | + | font-size: var(--text-xs); | |
| 615 | + | cursor: default; | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | /* --- Avatars --- */ | |
| 619 | + | .avatar { | |
| 620 | + | display: inline-flex; | |
| 621 | + | border-radius: 4px; | |
| 622 | + | overflow: hidden; | |
| 623 | + | flex-shrink: 0; | |
| 624 | + | vertical-align: middle; | |
| 625 | + | } | |
| 626 | + | .avatar-img { | |
| 627 | + | width: 100%; | |
| 628 | + | height: 100%; | |
| 629 | + | object-fit: cover; | |
| 630 | + | display: block; | |
| 631 | + | color: transparent; | |
| 632 | + | } | |
| 633 | + | .avatar-placeholder { | |
| 634 | + | display: inline-flex; | |
| 635 | + | align-items: center; | |
| 636 | + | justify-content: center; | |
| 637 | + | border-radius: 4px; | |
| 638 | + | background: var(--color-bg-inset); | |
| 639 | + | color: var(--color-text-muted); | |
| 640 | + | font-weight: 600; | |
| 641 | + | flex-shrink: 0; | |
| 642 | + | vertical-align: middle; | |
| 643 | + | } | |
| 644 | + | .avatar-settings { | |
| 645 | + | display: flex; | |
| 646 | + | align-items: flex-start; | |
| 647 | + | gap: var(--space-5); | |
| 648 | + | } | |
| 649 | + | .avatar-actions { | |
| 650 | + | display: flex; | |
| 651 | + | flex-direction: column; | |
| 652 | + | gap: var(--space-3); | |
| 653 | + | } | |
| 654 | + | .nav-user { | |
| 655 | + | display: inline-flex; | |
| 656 | + | align-items: center; | |
| 657 | + | gap: var(--space-2); | |
| 658 | + | font-size: var(--text-sm); | |
| 659 | + | color: var(--color-text-muted); | |
| 660 | + | } | |
| 661 | + | .timeline-body { | |
| 662 | + | padding: var(--space-4); | |
| 663 | + | } | |
| 664 | + | .timeline-item-new { | |
| 665 | + | padding: var(--space-4); | |
| 666 | + | } | |
| 667 | + | .timeline-item-new .section-title { | |
| 668 | + | font-size: var(--text-base); | |
| 669 | + | border: none; | |
| 670 | + | padding-bottom: 0; | |
| 671 | + | } | |
| 672 | + | /* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */ | |
| 673 | + | .timeline-author-right .inline-edit-details { | |
| 674 | + | border: none; | |
| 675 | + | } | |
| 676 | + | .inline-edit-details .when-open { | |
| 677 | + | display: none; | |
| 678 | + | } | |
| 679 | + | .inline-edit-details[open] .when-closed { | |
| 680 | + | display: none; | |
| 681 | + | } | |
| 682 | + | .inline-edit-details[open] .when-open { | |
| 683 | + | display: inline; | |
| 684 | + | } | |
| 685 | + | .inline-edit-form-area { | |
| 686 | + | display: none; | |
| 687 | + | border-top: 1px solid var(--color-border-muted); | |
| 688 | + | } | |
| 689 | + | .timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area { | |
| 690 | + | display: block; | |
| 691 | + | } | |
| 692 | + | .timeline-item:has(.inline-edit-details[open]) .timeline-body { | |
| 693 | + | display: none; | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | /* Title edit in issue/patch header */ | |
| 697 | + | .issue-detail-header { | |
| 698 | + | align-items: flex-start; | |
| 699 | + | } | |
| 700 | + | .title-with-edit { | |
| 701 | + | flex: 1; | |
| 702 | + | min-width: 16rem; | |
| 703 | + | display: flex; | |
| 704 | + | flex-direction: column; | |
| 705 | + | gap: var(--space-1); | |
| 706 | + | } | |
| 707 | + | .title-edit-details { | |
| 708 | + | border: none; | |
| 709 | + | align-self: flex-start; | |
| 710 | + | } | |
| 711 | + | .title-with-edit:has(.title-edit-details[open]) .issue-detail-title { | |
| 712 | + | display: none; | |
| 713 | + | } | |
| 714 | + | .title-with-edit:has(.title-edit-details[open]) > .title-edit-details { | |
| 715 | + | display: none; | |
| 716 | + | } | |
| 717 | + | .title-edit-details > summary { | |
| 718 | + | list-style: none; | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | .title-edit-form-area { | |
| 722 | + | display: none; | |
| 723 | + | } | |
| 724 | + | .title-with-edit:has(.title-edit-details[open]) .title-edit-form-area { | |
| 725 | + | display: flex; | |
| 726 | + | flex: 1; | |
| 727 | + | min-width: 0; | |
| 728 | + | } | |
| 729 | + | .title-edit-form { | |
| 730 | + | display: flex; | |
| 731 | + | flex-direction: column; | |
| 732 | + | gap: var(--space-2); | |
| 733 | + | flex: 1; | |
| 734 | + | min-width: 0; | |
| 735 | + | } | |
| 736 | + | .title-edit-form .form-input { | |
| 737 | + | min-width: 0; | |
| 738 | + | width: 100%; | |
| 739 | + | padding: var(--space-2) var(--space-3); | |
| 740 | + | border: 1px solid var(--color-border); | |
| 741 | + | border-radius: var(--radius-md); | |
| 742 | + | background: var(--color-bg); | |
| 743 | + | color: var(--color-text); | |
| 744 | + | font-size: var(--text-sm); | |
| 745 | + | } | |
| 746 | + | .title-edit-form .form-input:focus { | |
| 747 | + | outline: 2px solid var(--color-accent); | |
| 748 | + | outline-offset: -1px; | |
| 749 | + | border-color: var(--color-accent); | |
| 750 | + | } | |
| 751 | + | .title-edit-actions { | |
| 752 | + | display: flex; | |
| 753 | + | gap: var(--space-2); | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | .inline-edit-form { | |
| 757 | + | padding: var(--space-4); | |
| 758 | + | display: flex; | |
| 759 | + | flex-direction: column; | |
| 760 | + | gap: var(--space-3); | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | /* --- Reactions --- */ | |
| 764 | + | .reaction-bar { | |
| 765 | + | display: flex; | |
| 766 | + | flex-wrap: wrap; | |
| 767 | + | gap: var(--space-2); | |
| 768 | + | padding: var(--space-2) var(--space-4); | |
| 769 | + | border-top: 1px solid var(--color-border-muted); | |
| 770 | + | } | |
| 771 | + | .reaction-form { | |
| 772 | + | display: inline; | |
| 773 | + | } | |
| 774 | + | .reaction-btn { | |
| 775 | + | display: inline-flex; | |
| 776 | + | align-items: center; | |
| 777 | + | gap: var(--space-1); | |
| 778 | + | padding: var(--space-1) var(--space-3); | |
| 779 | + | border: 1px solid var(--color-border); | |
| 780 | + | border-radius: var(--radius-full); | |
| 781 | + | background: var(--color-bg-subtle); | |
| 782 | + | color: var(--color-text); | |
| 783 | + | font-size: var(--text-sm); | |
| 784 | + | cursor: pointer; | |
| 785 | + | transition: background 0.1s; | |
| 786 | + | } | |
| 787 | + | .reaction-btn:hover { | |
| 788 | + | background: var(--color-bg-inset); | |
| 789 | + | } | |
| 790 | + | .reaction-btn.reacted { | |
| 791 | + | background: var(--color-accent-bg); | |
| 792 | + | border-color: var(--color-accent); | |
| 793 | + | } | |
| 794 | + | .reaction-picker { | |
| 795 | + | position: relative; | |
| 796 | + | } | |
| 797 | + | .reaction-add-btn { | |
| 798 | + | display: inline-flex; | |
| 799 | + | align-items: center; | |
| 800 | + | justify-content: center; | |
| 801 | + | width: 28px; | |
| 802 | + | height: 28px; | |
| 803 | + | border: 1px dashed var(--color-border); | |
| 804 | + | border-radius: var(--radius-full); | |
| 805 | + | background: transparent; | |
| 806 | + | cursor: pointer; | |
| 807 | + | font-size: var(--text-base); | |
| 808 | + | color: var(--color-text-muted); | |
| 809 | + | list-style: none; | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | .reaction-picker-dropdown { | |
| 813 | + | position: absolute; | |
| 814 | + | bottom: calc(100% + var(--space-2)); | |
| 815 | + | left: 0; | |
| 816 | + | background: var(--color-bg); | |
| 817 | + | border: 1px solid var(--color-border); | |
| 818 | + | border-radius: var(--radius-lg); | |
| 819 | + | padding: var(--space-2); | |
| 820 | + | display: flex; | |
| 821 | + | gap: var(--space-1); | |
| 822 | + | z-index: 10; | |
| 823 | + | } | |
| 824 | + | .reaction-picker-btn { | |
| 825 | + | background: none; | |
| 826 | + | border: none; | |
| 827 | + | font-size: 1.25rem; | |
| 828 | + | cursor: pointer; | |
| 829 | + | padding: var(--space-1); | |
| 830 | + | border-radius: var(--radius-sm); | |
| 831 | + | } | |
| 832 | + | .reaction-picker-btn:hover { | |
| 833 | + | background: var(--color-bg-inset); | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | /* --- Patch subview tabs --- */ | |
| 837 | + | .patch-tab-nav { | |
| 838 | + | display: flex; | |
| 839 | + | border-bottom: 1px solid var(--color-border); | |
| 840 | + | margin-bottom: var(--space-6); | |
| 841 | + | } | |
| 842 | + | .tab-count { | |
| 843 | + | display: inline-flex; | |
| 844 | + | align-items: center; | |
| 845 | + | justify-content: center; | |
| 846 | + | min-width: 1.4em; | |
| 847 | + | padding: 0 var(--space-1); | |
| 848 | + | margin-left: var(--space-1); | |
| 849 | + | font-size: var(--text-xs); | |
| 850 | + | font-weight: 500; | |
| 851 | + | background: var(--color-bg-inset); | |
| 852 | + | border-radius: 999px; | |
| 853 | + | color: var(--color-text-muted); | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | /* --- Patch apply status --- */ | |
| 857 | + | .apply-status { | |
| 858 | + | margin: var(--space-4) 0; | |
| 859 | + | } | |
| 860 | + | .apply-result { | |
| 861 | + | display: flex; | |
| 862 | + | align-items: flex-start; | |
| 863 | + | gap: var(--space-2); | |
| 864 | + | padding: var(--space-3) var(--space-4); | |
| 865 | + | border-radius: var(--radius-md); | |
| 866 | + | font-size: var(--text-sm); | |
| 867 | + | flex-direction: column; | |
| 868 | + | } | |
| 869 | + | .apply-result > div { | |
| 870 | + | display: flex; | |
| 871 | + | gap: var(--space-2); | |
| 872 | + | align-items: center; | |
| 873 | + | } | |
| 874 | + | .apply-clean { | |
| 875 | + | background: var(--color-success-bg); | |
| 876 | + | color: var(--color-success); | |
| 877 | + | border: 1px solid var(--color-success); | |
| 878 | + | } | |
| 879 | + | .apply-conflict { | |
| 880 | + | background: var(--color-danger-bg); | |
| 881 | + | color: var(--color-danger); | |
| 882 | + | border: 1px solid var(--color-danger); | |
| 883 | + | } | |
| 884 | + | .apply-checking, | |
| 885 | + | .apply-unknown { | |
| 886 | + | color: var(--color-text-muted); | |
| 887 | + | font-style: italic; | |
| 888 | + | font-size: var(--text-sm); | |
| 889 | + | } | |
| 890 | + | .apply-output { | |
| 891 | + | font-family: var(--font-mono); | |
| 892 | + | font-size: var(--text-xs); | |
| 893 | + | white-space: pre-wrap; | |
| 894 | + | margin-top: var(--space-2); | |
| 895 | + | padding: var(--space-2); | |
| 896 | + | background: rgba(0, 0, 0, 0.05); | |
| 897 | + | border-radius: var(--radius-sm); | |
| 898 | + | width: 100%; | |
| 899 | + | } | |
| 900 | + | .patch-actions { | |
| 901 | + | display: flex; | |
| 902 | + | gap: var(--space-3); | |
| 903 | + | margin: var(--space-4) 0; | |
| 904 | + | flex-wrap: wrap; | |
| 905 | + | } | |
| 906 | + | .patch-card-title-row { | |
| 907 | + | display: flex; | |
| 908 | + | align-items: flex-start; | |
| 909 | + | gap: var(--space-3); | |
| 910 | + | justify-content: space-between; | |
| 911 | + | margin-bottom: var(--space-3); | |
| 912 | + | } | |
| 913 | + | .patch-card-title-row .commit-card-subject { | |
| 914 | + | margin-bottom: 0; | |
| 915 | + | } | |
| 916 | + | .patch-card-description { | |
| 917 | + | margin-top: var(--space-4); | |
| 918 | + | padding-top: var(--space-4); | |
| 919 | + | border-top: 1px solid var(--color-border-muted); | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | /* --- Repo home layout --- */ | |
| 923 | + | .repo-home-layout { | |
| 924 | + | display: grid; | |
| 925 | + | gap: var(--space-8); | |
| 926 | + | } | |
| 927 | + | @media (min-width: 768px) { | |
| 928 | + | .repo-home-layout { | |
| 929 | + | grid-template-columns: 1fr 1fr; | |
| 930 | + | } | |
| 931 | + | } | |
| 932 | + | .readme-section { | |
| 933 | + | border: 1px solid var(--color-border); | |
| 934 | + | border-radius: var(--radius-lg); | |
| 935 | + | overflow: hidden; | |
| 936 | + | } | |
| 937 | + | .readme-header { | |
| 938 | + | background: var(--color-bg-subtle); | |
| 939 | + | padding: var(--space-3) var(--space-4); | |
| 940 | + | font-size: var(--text-sm); | |
| 941 | + | font-weight: 500; | |
| 942 | + | border-bottom: 1px solid var(--color-border); | |
| 943 | + | display: flex; | |
| 944 | + | align-items: center; | |
| 945 | + | justify-content: space-between; | |
| 946 | + | } | |
| 947 | + | ||
| 948 | + | /* --- Markdown body --- */ | |
| 949 | + | .markdown-body { | |
| 950 | + | font-size: var(--text-sm); | |
| 951 | + | line-height: 1.6; | |
| 952 | + | color: var(--color-text); | |
| 953 | + | } | |
| 954 | + | .markdown-body h1, | |
| 955 | + | .markdown-body h2, | |
| 956 | + | .markdown-body h3, | |
| 957 | + | .markdown-body h4, | |
| 958 | + | .markdown-body h5, | |
| 959 | + | .markdown-body h6 { | |
| 960 | + | margin-top: var(--space-6); | |
| 961 | + | margin-bottom: var(--space-3); | |
| 962 | + | } | |
| 963 | + | .markdown-body p { | |
| 964 | + | margin-bottom: var(--space-4); | |
| 965 | + | } | |
| 966 | + | .markdown-body a { | |
| 967 | + | color: var(--color-link); | |
| 968 | + | } | |
| 969 | + | .markdown-body code { | |
| 970 | + | background: var(--color-bg-inset); | |
| 971 | + | padding: 0.1em 0.3em; | |
| 972 | + | border-radius: var(--radius-sm); | |
| 973 | + | font-size: 0.9em; | |
| 974 | + | } | |
| 975 | + | .markdown-body pre { | |
| 976 | + | background: var(--color-bg-subtle); | |
| 977 | + | border: 1px solid var(--color-border); | |
| 978 | + | border-radius: var(--radius-md); | |
| 979 | + | padding: var(--space-4); | |
| 980 | + | overflow: auto; | |
| 981 | + | margin-bottom: var(--space-4); | |
| 982 | + | } | |
| 983 | + | .markdown-body pre code { | |
| 984 | + | background: none; | |
| 985 | + | padding: 0; | |
| 986 | + | } | |
| 987 | + | .markdown-body ul, | |
| 988 | + | .markdown-body ol { | |
| 989 | + | padding-left: var(--space-6); | |
| 990 | + | margin-bottom: var(--space-4); | |
| 991 | + | } | |
| 992 | + | .markdown-body ul { | |
| 993 | + | list-style: disc; | |
| 994 | + | } | |
| 995 | + | .markdown-body ol { | |
| 996 | + | list-style: decimal; | |
| 997 | + | } | |
| 998 | + | .markdown-body li { | |
| 999 | + | margin-bottom: var(--space-1); | |
| 1000 | + | } | |
| 1001 | + | .markdown-body blockquote { | |
| 1002 | + | border-left: 3px solid var(--color-border); | |
| 1003 | + | padding-left: var(--space-4); | |
| 1004 | + | color: var(--color-text-muted); | |
| 1005 | + | margin: var(--space-4) 0; | |
| 1006 | + | } | |
| 1007 | + | .markdown-body table { | |
| 1008 | + | width: 100%; | |
| 1009 | + | border-collapse: collapse; | |
| 1010 | + | margin-bottom: var(--space-4); | |
| 1011 | + | } | |
| 1012 | + | .markdown-body th, | |
| 1013 | + | .markdown-body td { | |
| 1014 | + | padding: var(--space-2) var(--space-3); | |
| 1015 | + | border: 1px solid var(--color-border); | |
| 1016 | + | } | |
| 1017 | + | .markdown-body th { | |
| 1018 | + | background: var(--color-bg-subtle); | |
| 1019 | + | font-weight: 600; | |
| 1020 | + | } | |
| 1021 | + | .markdown-body hr { | |
| 1022 | + | border: none; | |
| 1023 | + | border-top: 1px solid var(--color-border); | |
| 1024 | + | margin: var(--space-6) 0; | |
| 1025 | + | } | |
| 1026 | + | .markdown-body img { | |
| 1027 | + | max-width: 100%; | |
| 1028 | + | border-radius: var(--radius-md); | |
| 1029 | + | } | |
| 1030 | + | .markdown-body details { | |
| 1031 | + | margin-bottom: var(--space-4); | |
| 1032 | + | } | |
| 1033 | + | .markdown-body summary { | |
| 1034 | + | cursor: pointer; | |
| 1035 | + | font-weight: 500; | |
| 1036 | + | } | |
| 1037 | + | .readme-section .markdown-body, | |
| 1038 | + | .file-blob-body .markdown-body { | |
| 1039 | + | padding: var(--space-4); | |
| 1040 | + | } | |
| 1041 | + | ||
| 1042 | + | /* --- Code setup block --- */ | |
| 1043 | + | .code-setup { | |
| 1044 | + | background: var(--color-bg-subtle); | |
| 1045 | + | border: 1px solid var(--color-border); | |
| 1046 | + | border-radius: var(--radius-lg); | |
| 1047 | + | padding: var(--space-4); | |
| 1048 | + | font-size: var(--text-sm); | |
| 1049 | + | margin-top: var(--space-4); | |
| 1050 | + | overflow: auto; | |
| 1051 | + | text-align: left; | |
| 1052 | + | } | |
| 1053 | + | .code-plain { | |
| 1054 | + | background: var(--color-bg-subtle); | |
| 1055 | + | padding: var(--space-4); | |
| 1056 | + | overflow: auto; | |
| 1057 | + | font-size: var(--text-sm); | |
| 1058 | + | line-height: 1.6; | |
| 1059 | + | } | |
| 1060 | + | ||
| 1061 | + | /* --- Search row (search form + sort select) --- */ | |
| 1062 | + | .search-row { | |
| 1063 | + | display: flex; | |
| 1064 | + | align-items: center; | |
| 1065 | + | gap: var(--space-3); | |
| 1066 | + | margin-bottom: var(--space-5); | |
| 1067 | + | } | |
| 1068 | + | .search-form { | |
| 1069 | + | flex: 1; | |
| 1070 | + | } | |
| 1071 | + | .repo-sort-select { | |
| 1072 | + | padding: var(--space-1) var(--space-2); | |
| 1073 | + | padding-right: var(--space-5); | |
| 1074 | + | border: 1px solid var(--color-border); | |
| 1075 | + | border-radius: var(--radius-md); | |
| 1076 | + | background: var(--color-bg-subtle); | |
| 1077 | + | font-size: var(--text-sm); | |
| 1078 | + | color: var(--color-text); | |
| 1079 | + | cursor: pointer; | |
| 1080 | + | appearance: auto; | |
| 1081 | + | } | |
| 1082 | + | .repo-sort-select:hover { | |
| 1083 | + | border-color: var(--color-text-muted); | |
| 1084 | + | } | |
| 1085 | + | .repo-sort-select:focus { | |
| 1086 | + | outline: 2px solid var(--color-accent); | |
| 1087 | + | outline-offset: -1px; | |
| 1088 | + | } | |
| 1089 | + | .search-input-wrap { | |
| 1090 | + | display: flex; | |
| 1091 | + | gap: var(--space-2); | |
| 1092 | + | } | |
| 1093 | + | .search-input { | |
| 1094 | + | flex: 1; | |
| 1095 | + | padding: var(--space-2) var(--space-3); | |
| 1096 | + | border: 1px solid var(--color-border); | |
| 1097 | + | border-radius: var(--radius-md); | |
| 1098 | + | background: var(--color-bg); | |
| 1099 | + | color: var(--color-text); | |
| 1100 | + | font-size: var(--text-sm); | |
| 1101 | + | } | |
| 1102 | + | .search-input:focus { | |
| 1103 | + | outline: 2px solid var(--color-accent); | |
| 1104 | + | outline-offset: -1px; | |
| 1105 | + | border-color: var(--color-accent); | |
| 1106 | + | } | |
| 1107 | + | ||
| 1108 | + | /* --- Issue close bar --- */ | |
| 1109 | + | .issue-close-bar { | |
| 1110 | + | display: flex; | |
| 1111 | + | justify-content: flex-end; | |
| 1112 | + | padding: var(--space-3) 0; | |
| 1113 | + | border-top: 1px solid var(--color-border-muted); | |
| 1114 | + | margin-top: var(--space-2); | |
| 1115 | + | } | |
| 1116 | + | ||
| 1117 | + | /* --- Auth divider --- */ | |
| 1118 | + | .auth-divider { | |
| 1119 | + | display: flex; | |
| 1120 | + | align-items: center; | |
| 1121 | + | gap: var(--space-3); | |
| 1122 | + | margin: var(--space-4) 0; | |
| 1123 | + | color: var(--color-text-muted); | |
| 1124 | + | font-size: var(--text-sm); | |
| 1125 | + | } | |
| 1126 | + | .auth-divider::before, | |
| 1127 | + | .auth-divider::after { | |
| 1128 | + | content: ""; | |
| 1129 | + | flex: 1; | |
| 1130 | + | height: 1px; | |
| 1131 | + | background: var(--color-border); | |
| 1132 | + | } | |
| 1133 | + | ||
| 1134 | + | /* --- Passkey UI in settings --- */ | |
| 1135 | + | .passkey-actions { | |
| 1136 | + | display: flex; | |
| 1137 | + | flex-direction: column; | |
| 1138 | + | gap: var(--space-3); | |
| 1139 | + | align-items: flex-start; | |
| 1140 | + | margin-top: var(--space-3); | |
| 1141 | + | } | |
| 1142 | + | .passkey-status { | |
| 1143 | + | font-size: var(--text-sm); | |
| 1144 | + | } | |
| 1145 | + | ||
| 1146 | + | /* --- Danger zone --- */ | |
| 1147 | + | .danger-zone { | |
| 1148 | + | margin-top: var(--space-8); | |
| 1149 | + | } | |
| 1150 | + | .danger-title { | |
| 1151 | + | color: var(--color-danger); | |
| 1152 | + | border-bottom-color: var(--color-danger); | |
| 1153 | + | } | |
| 1154 | + | .danger-card { | |
| 1155 | + | border-color: var(--color-danger); | |
| 1156 | + | background: var(--color-danger-bg); | |
| 1157 | + | } | |
| 1158 | + | .danger-item { | |
| 1159 | + | display: flex; | |
| 1160 | + | align-items: center; | |
| 1161 | + | justify-content: space-between; | |
| 1162 | + | gap: var(--space-4); | |
| 1163 | + | flex-wrap: wrap; | |
| 1164 | + | } | |
| 1165 | + | .danger-item p { | |
| 1166 | + | margin: var(--space-1) 0 0; | |
| 1167 | + | } | |
| 1168 | + | .btn-danger { | |
| 1169 | + | background: var(--color-danger); | |
| 1170 | + | border-color: transparent; | |
| 1171 | + | color: #fff; | |
| 1172 | + | } | |
| 1173 | + | .btn-danger:hover { | |
| 1174 | + | background: color-mix(in srgb, var(--color-danger) 85%, #000); | |
| 1175 | + | } | |
| 1176 | + | ||
| 1177 | + | /* --- Release list items --- */ | |
| 1178 | + | .release-item-header { | |
| 1179 | + | display: flex; | |
| 1180 | + | gap: var(--space-3); | |
| 1181 | + | align-items: flex-start; | |
| 1182 | + | } | |
| 1183 | + | .release-item-main { | |
| 1184 | + | flex: 1; | |
| 1185 | + | min-width: 0; | |
| 1186 | + | } | |
| 1187 | + | .release-item-title { | |
| 1188 | + | font-size: var(--text-lg); | |
| 1189 | + | font-weight: 700; | |
| 1190 | + | text-decoration: none; | |
| 1191 | + | color: var(--color-text); | |
| 1192 | + | display: block; | |
| 1193 | + | margin-bottom: var(--space-1); | |
| 1194 | + | } | |
| 1195 | + | .release-item-title:hover { | |
| 1196 | + | color: var(--color-link); | |
| 1197 | + | } | |
| 1198 | + | .release-item-meta { | |
| 1199 | + | display: flex; | |
| 1200 | + | flex-wrap: wrap; | |
| 1201 | + | gap: var(--space-3); | |
| 1202 | + | align-items: center; | |
| 1203 | + | font-size: var(--text-xs); | |
| 1204 | + | color: var(--color-text-muted); | |
| 1205 | + | } | |
| 1206 | + | .release-item-date { | |
| 1207 | + | display: flex; | |
| 1208 | + | flex-direction: column; | |
| 1209 | + | align-items: flex-end; | |
| 1210 | + | gap: var(--space-2); | |
| 1211 | + | font-size: var(--text-xs); | |
| 1212 | + | color: var(--color-text-muted); | |
| 1213 | + | white-space: nowrap; | |
| 1214 | + | flex-shrink: 0; | |
| 1215 | + | } | |
| 1216 | + | ||
| 1217 | + | /* --- Release notes preview in list --- */ | |
| 1218 | + | .release-notes-section { | |
| 1219 | + | margin-top: var(--space-3); | |
| 1220 | + | } | |
| 1221 | + | .release-notes-label { | |
| 1222 | + | font-size: var(--text-xs); | |
| 1223 | + | font-weight: 600; | |
| 1224 | + | color: var(--color-text-muted); | |
| 1225 | + | text-transform: uppercase; | |
| 1226 | + | letter-spacing: 0.05em; | |
| 1227 | + | margin-bottom: var(--space-2); | |
| 1228 | + | } | |
| 1229 | + | .notes-expand > summary { | |
| 1230 | + | list-style: none; | |
| 1231 | + | cursor: pointer; | |
| 1232 | + | outline: none; | |
| 1233 | + | } | |
| 1234 | + | ||
| 1235 | + | .notes-preview { | |
| 1236 | + | max-height: calc(5 * 1.6em); | |
| 1237 | + | overflow: hidden; | |
| 1238 | + | position: relative; | |
| 1239 | + | white-space: pre-wrap; | |
| 1240 | + | font-size: var(--text-sm); | |
| 1241 | + | color: var(--color-text); | |
| 1242 | + | line-height: 1.6; | |
| 1243 | + | word-break: break-word; | |
| 1244 | + | margin-bottom: var(--space-1); | |
| 1245 | + | } | |
| 1246 | + | .notes-preview::after { | |
| 1247 | + | content: ""; | |
| 1248 | + | position: absolute; | |
| 1249 | + | bottom: 0; | |
| 1250 | + | left: 0; | |
| 1251 | + | right: 0; | |
| 1252 | + | height: 2.5em; | |
| 1253 | + | background: linear-gradient(transparent, var(--color-bg)); | |
| 1254 | + | pointer-events: none; | |
| 1255 | + | } | |
| 1256 | + | .notes-expand[open] .notes-preview { | |
| 1257 | + | display: none; | |
| 1258 | + | } | |
| 1259 | + | .notes-full { | |
| 1260 | + | padding-top: var(--space-3); | |
| 1261 | + | padding-bottom: var(--space-1); | |
| 1262 | + | } | |
| 1263 | + | .notes-expand[open] { | |
| 1264 | + | display: flex; | |
| 1265 | + | flex-direction: column; | |
| 1266 | + | } | |
| 1267 | + | .notes-expand[open] > summary { | |
| 1268 | + | order: 1; | |
| 1269 | + | } | |
| 1270 | + | .notes-expand[open] > .notes-full { | |
| 1271 | + | order: 0; | |
| 1272 | + | } | |
| 1273 | + | .notes-toggle-label { | |
| 1274 | + | display: inline-block; | |
| 1275 | + | font-size: var(--text-xs); | |
| 1276 | + | color: var(--color-link); | |
| 1277 | + | margin-top: var(--space-1); | |
| 1278 | + | } | |
| 1279 | + | .notes-toggle-label:hover { | |
| 1280 | + | text-decoration: underline; | |
| 1281 | + | } | |
| 1282 | + | .notes-toggle-label::before { | |
| 1283 | + | content: "Show more ↓"; | |
| 1284 | + | } | |
| 1285 | + | .notes-expand[open] .notes-toggle-label::before { | |
| 1286 | + | content: "Show less ↑"; | |
| 1287 | + | } | |
| 1288 | + | ||
| 1289 | + | /* --- Release detail --- */ | |
| 1290 | + | .release-detail { | |
| 1291 | + | padding: var(--space-6) 0; | |
| 1292 | + | } | |
| 1293 | + | .release-header { | |
| 1294 | + | margin-bottom: var(--space-6); | |
| 1295 | + | } | |
| 1296 | + | .release-notes { | |
| 1297 | + | margin-bottom: var(--space-6); | |
| 1298 | + | } | |
| 1299 | + | .release-assets { | |
| 1300 | + | margin-bottom: var(--space-6); | |
| 1301 | + | } | |
| 1302 | + | .release-assets-heading { | |
| 1303 | + | font-size: var(--text-base); | |
| 1304 | + | font-weight: 600; | |
| 1305 | + | margin-bottom: var(--space-3); | |
| 1306 | + | } | |
| 1307 | + | .asset-list { | |
| 1308 | + | border: 1px solid var(--color-border); | |
| 1309 | + | border-radius: var(--radius-lg); | |
| 1310 | + | overflow: hidden; | |
| 1311 | + | } | |
| 1312 | + | .asset-item { | |
| 1313 | + | display: flex; | |
| 1314 | + | align-items: center; | |
| 1315 | + | gap: var(--space-4); | |
| 1316 | + | padding: var(--space-3) var(--space-4); | |
| 1317 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 1318 | + | font-size: var(--text-sm); | |
| 1319 | + | } | |
| 1320 | + | .asset-item:last-child { | |
| 1321 | + | border-bottom: none; | |
| 1322 | + | } | |
| 1323 | + | .asset-name { | |
| 1324 | + | flex: 1; | |
| 1325 | + | color: var(--color-link); | |
| 1326 | + | text-decoration: none; | |
| 1327 | + | font-family: var(--font-mono); | |
| 1328 | + | font-size: var(--text-xs); | |
| 1329 | + | } | |
| 1330 | + | .asset-name:hover { | |
| 1331 | + | text-decoration: underline; | |
| 1332 | + | } | |
| 1333 | + | .asset-name-pending { | |
| 1334 | + | color: var(--color-text-muted); | |
| 1335 | + | cursor: default; | |
| 1336 | + | } | |
| 1337 | + | .asset-item-pending { | |
| 1338 | + | opacity: 0.6; | |
| 1339 | + | } | |
| 1340 | + | .asset-size, | |
| 1341 | + | .asset-meta { | |
| 1342 | + | color: var(--color-text-muted); | |
| 1343 | + | font-size: var(--text-xs); | |
| 1344 | + | white-space: nowrap; | |
| 1345 | + | } | |
| 1346 | + | .release-back { | |
| 1347 | + | margin-top: var(--space-4); | |
| 1348 | + | display: flex; | |
| 1349 | + | gap: var(--space-3); | |
| 1350 | + | align-items: center; | |
| 1351 | + | justify-content: space-between; | |
| 1352 | + | } | |
| 1353 | + | ||
| 1354 | + | /* ============================================================ | |
| 1355 | + | Pagination | |
| 1356 | + | ============================================================ */ | |
| 1357 | + | .commit-verify-hint { | |
| 1358 | + | margin-bottom: var(--space-4); | |
| 1359 | + | font-size: var(--text-xs); | |
| 1360 | + | color: var(--color-text-muted); | |
| 1361 | + | } | |
| 1362 | + | .commit-verify-hint code { | |
| 1363 | + | word-break: break-all; | |
| 1364 | + | } | |
| 1365 | + | .commit-cursor-nav { | |
| 1366 | + | display: flex; | |
| 1367 | + | justify-content: space-between; | |
| 1368 | + | border-top: 1px solid var(--color-border); | |
| 1369 | + | margin-top: var(--space-2); | |
| 1370 | + | padding: var(--space-4) 0 var(--space-2); | |
| 1371 | + | } | |
| 1372 | + | ||
| 1373 | + | .commit-cursor-prev { | |
| 1374 | + | flex: 1; | |
| 1375 | + | } | |
| 1376 | + | .commit-cursor-next { | |
| 1377 | + | flex: 1; | |
| 1378 | + | text-align: right; | |
| 1379 | + | } | |
| 1380 | + | ||
| 1381 | + | .pagination { | |
| 1382 | + | display: grid; | |
| 1383 | + | grid-template-columns: 1fr auto 1fr; | |
| 1384 | + | align-items: center; | |
| 1385 | + | border-top: 1px solid var(--color-border); | |
| 1386 | + | margin-top: var(--space-2); | |
| 1387 | + | padding: var(--space-4) 0 var(--space-2); | |
| 1388 | + | } | |
| 1389 | + | ||
| 1390 | + | .pagination-prev { | |
| 1391 | + | justify-self: start; | |
| 1392 | + | } | |
| 1393 | + | .pagination-next { | |
| 1394 | + | justify-self: end; | |
| 1395 | + | } | |
| 1396 | + | ||
| 1397 | + | .pagination-info { | |
| 1398 | + | justify-self: center; | |
| 1399 | + | font-size: var(--text-sm); | |
| 1400 | + | color: var(--color-text-muted); | |
| 1401 | + | } | |
| 1402 | + | ||
| 1403 | + | .pagination-btn { | |
| 1404 | + | display: inline-flex; | |
| 1405 | + | align-items: center; | |
| 1406 | + | gap: var(--space-1); | |
| 1407 | + | padding: var(--space-1) var(--space-3); | |
| 1408 | + | border: 1px solid var(--color-border); | |
| 1409 | + | border-radius: var(--radius-md); | |
| 1410 | + | font-size: var(--text-sm); | |
| 1411 | + | font-weight: 500; | |
| 1412 | + | text-decoration: none; | |
| 1413 | + | color: var(--color-text); | |
| 1414 | + | background: var(--color-bg-subtle); | |
| 1415 | + | transition: | |
| 1416 | + | background 0.1s, | |
| 1417 | + | border-color 0.1s; | |
| 1418 | + | white-space: nowrap; | |
| 1419 | + | } | |
| 1420 | + | ||
| 1421 | + | .pagination-btn:hover { | |
| 1422 | + | background: var(--color-bg-inset); | |
| 1423 | + | border-color: var(--color-border); | |
| 1424 | + | color: var(--color-text); | |
| 1425 | + | text-decoration: none; | |
| 1426 | + | } | |
| 1427 | + | ||
| 1428 | + | /* Settings */ | |
| 1429 | + | .theme-options { | |
| 1430 | + | display: flex; | |
| 1431 | + | gap: var(--space-4); | |
| 1432 | + | margin-bottom: var(--space-4); | |
| 1433 | + | flex-wrap: wrap; | |
| 1434 | + | } | |
| 1435 | + | .theme-option { | |
| 1436 | + | display: flex; | |
| 1437 | + | align-items: center; | |
| 1438 | + | gap: var(--space-2); | |
| 1439 | + | cursor: pointer; | |
| 1440 | + | font-size: var(--text-sm); | |
| 1441 | + | } | |
| 1442 | + | .settings-form { | |
| 1443 | + | display: flex; | |
| 1444 | + | flex-direction: column; | |
| 1445 | + | gap: var(--space-4); | |
| 1446 | + | } | |
| 1447 | + | .passkey-list { | |
| 1448 | + | display: flex; | |
| 1449 | + | flex-direction: column; | |
| 1450 | + | gap: var(--space-2); | |
| 1451 | + | margin-bottom: var(--space-4); | |
| 1452 | + | } | |
| 1453 | + | .passkey-item { | |
| 1454 | + | display: flex; | |
| 1455 | + | align-items: center; | |
| 1456 | + | gap: var(--space-3); | |
| 1457 | + | padding: var(--space-2) 0; | |
| 1458 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 1459 | + | font-size: var(--text-sm); | |
| 1460 | + | } | |
| 1461 | + | .passkey-item:last-child { | |
| 1462 | + | border-bottom: none; | |
| 1463 | + | } | |
| 1464 | + | .passkey-date { | |
| 1465 | + | flex: 1; | |
| 1466 | + | color: var(--color-text-muted); | |
| 1467 | + | } | |
| 1468 | + | .queue-bulk-actions { | |
| 1469 | + | display: flex; | |
| 1470 | + | gap: var(--space-2); | |
| 1471 | + | margin-bottom: var(--space-4); | |
| 1472 | + | } | |
| 1473 | + | .queue-list { | |
| 1474 | + | list-style: none; | |
| 1475 | + | padding: 0; | |
| 1476 | + | margin: 0; | |
| 1477 | + | max-height: 32rem; | |
| 1478 | + | overflow-y: auto; | |
| 1479 | + | } | |
| 1480 | + | .queue-item { | |
| 1481 | + | display: flex; | |
| 1482 | + | align-items: flex-start; | |
| 1483 | + | justify-content: space-between; | |
| 1484 | + | gap: var(--space-4); | |
| 1485 | + | padding: var(--space-3) 0; | |
| 1486 | + | border-top: 1px solid var(--color-border-muted); | |
| 1487 | + | font-size: var(--text-sm); | |
| 1488 | + | } | |
| 1489 | + | .queue-item-meta { | |
| 1490 | + | display: flex; | |
| 1491 | + | flex-direction: column; | |
| 1492 | + | gap: var(--space-1); | |
| 1493 | + | min-width: 0; | |
| 1494 | + | } | |
| 1495 | + | .queue-item-header { | |
| 1496 | + | display: flex; | |
| 1497 | + | align-items: baseline; | |
| 1498 | + | gap: var(--space-2); | |
| 1499 | + | } | |
| 1500 | + | .queue-item-date { | |
| 1501 | + | color: var(--color-text-muted); | |
| 1502 | + | font-size: var(--text-xs); | |
| 1503 | + | } | |
| 1504 | + | .queue-item-answer { | |
| 1505 | + | margin: 0; | |
| 1506 | + | color: var(--color-text-muted); | |
| 1507 | + | font-size: var(--text-xs); | |
| 1508 | + | white-space: pre-wrap; | |
| 1509 | + | word-break: break-word; | |
| 1510 | + | } | |
| 1511 | + | .queue-item-actions { | |
| 1512 | + | display: flex; | |
| 1513 | + | gap: var(--space-2); | |
| 1514 | + | flex-shrink: 0; | |
| 1515 | + | } | |
| 1516 | + | .ssh-key-info { | |
| 1517 | + | flex: 1; | |
| 1518 | + | display: flex; | |
| 1519 | + | flex-direction: column; | |
| 1520 | + | gap: var(--space-1); | |
| 1521 | + | min-width: 0; | |
| 1522 | + | } | |
| 1523 | + | .ssh-key-name { | |
| 1524 | + | font-weight: 500; | |
| 1525 | + | } | |
| 1526 | + | .ssh-key-fingerprint { | |
| 1527 | + | font-family: var(--font-mono); | |
| 1528 | + | font-size: var(--text-xs); | |
| 1529 | + | overflow: hidden; | |
| 1530 | + | text-overflow: ellipsis; | |
| 1531 | + | white-space: nowrap; | |
| 1532 | + | } | |
| 1533 | + | .form-textarea { | |
| 1534 | + | resize: vertical; | |
| 1535 | + | min-height: 4.5rem; | |
| 1536 | + | font-family: var(--font-mono); | |
| 1537 | + | font-size: var(--text-sm); | |
| 1538 | + | } | |
| 1539 | + | ||
| 1540 | + | /* Clone popup */ | |
| 1541 | + | .clone-popup-details { | |
| 1542 | + | position: relative; | |
| 1543 | + | display: inline-block; | |
| 1544 | + | } | |
| 1545 | + | .clone-popup-details > summary { | |
| 1546 | + | list-style: none; | |
| 1547 | + | } | |
| 1548 | + | .timeline-author-right .inline-edit-details > summary { | |
| 1549 | + | list-style: none; | |
| 1550 | + | } | |
| 1551 | + | .clone-popup { | |
| 1552 | + | position: absolute; | |
| 1553 | + | right: 1rem; | |
| 1554 | + | top: calc(100% + var(--space-2)); | |
| 1555 | + | background: var(--color-bg); | |
| 1556 | + | border: 1px solid var(--color-border); | |
| 1557 | + | border-radius: var(--radius-lg); | |
| 1558 | + | padding: var(--space-3); | |
| 1559 | + | min-width: 22rem; | |
| 1560 | + | z-index: 100; | |
| 1561 | + | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); | |
| 1562 | + | display: flex; | |
| 1563 | + | flex-direction: column; | |
| 1564 | + | gap: var(--space-2); | |
| 1565 | + | } | |
| 1566 | + | .clone-url-row { | |
| 1567 | + | display: flex; | |
| 1568 | + | align-items: center; | |
| 1569 | + | gap: var(--space-2); | |
| 1570 | + | } | |
| 1571 | + | .clone-url-label { | |
| 1572 | + | font-size: var(--text-xs); | |
| 1573 | + | font-weight: 600; | |
| 1574 | + | color: var(--color-text-muted); | |
| 1575 | + | text-transform: uppercase; | |
| 1576 | + | letter-spacing: 0.05em; | |
| 1577 | + | min-width: 2.75rem; | |
| 1578 | + | } | |
| 1579 | + | .clone-url-input { | |
| 1580 | + | flex: 1; | |
| 1581 | + | font-family: var(--font-mono); | |
| 1582 | + | font-size: var(--text-sm); | |
| 1583 | + | background: var(--color-bg-subtle); | |
| 1584 | + | border: 1px solid var(--color-border-muted); | |
| 1585 | + | border-radius: var(--radius-md); | |
| 1586 | + | padding: var(--space-1) var(--space-2); | |
| 1587 | + | color: var(--color-text); | |
| 1588 | + | cursor: text; | |
| 1589 | + | min-width: 0; | |
| 1590 | + | } | |
| 1591 | + | ||
| 1592 | + | /* User list */ | |
| 1593 | + | .user-list { | |
| 1594 | + | display: flex; | |
| 1595 | + | flex-direction: column; | |
| 1596 | + | gap: var(--space-2); | |
| 1597 | + | margin-bottom: var(--space-6); | |
| 1598 | + | } | |
| 1599 | + | .user-item { | |
| 1600 | + | display: flex; | |
| 1601 | + | align-items: center; | |
| 1602 | + | gap: var(--space-3); | |
| 1603 | + | padding: var(--space-2) 0; | |
| 1604 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 1605 | + | font-size: var(--text-sm); | |
| 1606 | + | } | |
| 1607 | + | .user-item:last-child { | |
| 1608 | + | border-bottom: none; | |
| 1609 | + | } | |
| 1610 | + | .user-name { | |
| 1611 | + | font-weight: 500; | |
| 1612 | + | flex: 1; | |
| 1613 | + | } | |
| 1614 | + | .user-date { | |
| 1615 | + | color: var(--color-text-muted); | |
| 1616 | + | font-size: var(--text-xs); | |
| 1617 | + | } | |
| 1618 | + | ||
| 1619 | + | /* Labels */ | |
| 1620 | + | .label-badge { | |
| 1621 | + | display: inline-block; | |
| 1622 | + | padding: 0 var(--space-2); | |
| 1623 | + | border-radius: 2em; | |
| 1624 | + | font-size: var(--text-xs); | |
| 1625 | + | font-weight: 500; | |
| 1626 | + | line-height: 1.6; | |
| 1627 | + | word-break: break-all; | |
| 1628 | + | } | |
| 1629 | + | .issue-labels { | |
| 1630 | + | display: flex; | |
| 1631 | + | flex-wrap: wrap; | |
| 1632 | + | gap: var(--space-1); | |
| 1633 | + | margin-bottom: var(--space-2); | |
| 1634 | + | } | |
| 1635 | + | .issue-labels-row { | |
| 1636 | + | display: flex; | |
| 1637 | + | flex-wrap: wrap; | |
| 1638 | + | align-items: center; | |
| 1639 | + | gap: var(--space-2); | |
| 1640 | + | padding: var(--space-2) 0 var(--space-3); | |
| 1641 | + | } | |
| 1642 | + | .label-badge-wrap { | |
| 1643 | + | display: inline-flex; | |
| 1644 | + | align-items: center; | |
| 1645 | + | gap: var(--space-1); | |
| 1646 | + | } | |
| 1647 | + | .label-remove-form { | |
| 1648 | + | display: inline; | |
| 1649 | + | } | |
| 1650 | + | .label-remove-btn { | |
| 1651 | + | background: none; | |
| 1652 | + | border: none; | |
| 1653 | + | cursor: pointer; | |
| 1654 | + | padding: 0 2px; | |
| 1655 | + | font-size: var(--text-sm); | |
| 1656 | + | line-height: 1; | |
| 1657 | + | color: var(--color-text-muted); | |
| 1658 | + | opacity: 0.7; | |
| 1659 | + | } | |
| 1660 | + | .label-remove-btn:hover { | |
| 1661 | + | opacity: 1; | |
| 1662 | + | } | |
| 1663 | + | .label-add-inline-form { | |
| 1664 | + | display: inline-flex; | |
| 1665 | + | align-items: center; | |
| 1666 | + | gap: var(--space-2); | |
| 1667 | + | } | |
| 1668 | + | .label-select { | |
| 1669 | + | font-size: var(--text-sm); | |
| 1670 | + | padding: var(--space-1) var(--space-2); | |
| 1671 | + | border: 1px solid var(--color-border); | |
| 1672 | + | border-radius: var(--radius-md); | |
| 1673 | + | background: var(--color-bg); | |
| 1674 | + | color: var(--color-text); | |
| 1675 | + | height: 2rem; | |
| 1676 | + | } | |
| 1677 | + | .label-checkbox-list { | |
| 1678 | + | display: flex; | |
| 1679 | + | flex-wrap: wrap; | |
| 1680 | + | gap: var(--space-2); | |
| 1681 | + | margin-top: var(--space-1); | |
| 1682 | + | } | |
| 1683 | + | .label-checkbox-item { | |
| 1684 | + | display: inline-flex; | |
| 1685 | + | align-items: center; | |
| 1686 | + | gap: var(--space-1); | |
| 1687 | + | cursor: pointer; | |
| 1688 | + | } | |
| 1689 | + | .label-settings-list { | |
| 1690 | + | display: flex; | |
| 1691 | + | flex-direction: column; | |
| 1692 | + | margin-bottom: var(--space-4); | |
| 1693 | + | max-height: 24rem; | |
| 1694 | + | overflow-y: auto; | |
| 1695 | + | } | |
| 1696 | + | .label-settings-item { | |
| 1697 | + | display: flex; | |
| 1698 | + | align-items: center; | |
| 1699 | + | gap: var(--space-3); | |
| 1700 | + | padding: var(--space-2) 0; | |
| 1701 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 1702 | + | font-size: var(--text-sm); | |
| 1703 | + | } | |
| 1704 | + | .label-settings-item:last-child { | |
| 1705 | + | border-bottom: none; | |
| 1706 | + | } | |
| 1707 | + | .label-settings-swatch { | |
| 1708 | + | width: 12px; | |
| 1709 | + | height: 12px; | |
| 1710 | + | border-radius: 50%; | |
| 1711 | + | flex-shrink: 0; | |
| 1712 | + | border: 1px solid rgba(0, 0, 0, 0.15); | |
| 1713 | + | } | |
| 1714 | + | .label-settings-name { | |
| 1715 | + | flex: 1; | |
| 1716 | + | font-weight: 500; | |
| 1717 | + | } | |
| 1718 | + | .label-add-form { | |
| 1719 | + | display: flex; | |
| 1720 | + | align-items: center; | |
| 1721 | + | gap: var(--space-2); | |
| 1722 | + | } | |
| 1723 | + | .label-name-input { | |
| 1724 | + | flex: 1; | |
| 1725 | + | min-width: 0; | |
| 1726 | + | padding: var(--space-2) var(--space-3); | |
| 1727 | + | border: 1px solid var(--color-border); | |
| 1728 | + | border-radius: var(--radius-md); | |
| 1729 | + | background: var(--color-bg); | |
| 1730 | + | color: var(--color-text); | |
| 1731 | + | font-size: var(--text-sm); | |
| 1732 | + | height: 2rem; | |
| 1733 | + | box-sizing: border-box; | |
| 1734 | + | } | |
| 1735 | + | .label-name-input:focus { | |
| 1736 | + | outline: 2px solid var(--color-accent); | |
| 1737 | + | outline-offset: -1px; | |
| 1738 | + | border-color: var(--color-accent); | |
| 1739 | + | } | |
| 1740 | + | .label-color-swatch-label { | |
| 1741 | + | flex-shrink: 0; | |
| 1742 | + | cursor: pointer; | |
| 1743 | + | display: block; | |
| 1744 | + | line-height: 0; | |
| 1745 | + | } | |
| 1746 | + | .label-color-input { | |
| 1747 | + | display: block; | |
| 1748 | + | width: 2rem; | |
| 1749 | + | height: 2rem; | |
| 1750 | + | cursor: pointer; | |
| 1751 | + | appearance: none; | |
| 1752 | + | box-sizing: border-box; | |
| 1753 | + | clip-path: circle(50%); | |
| 1754 | + | border: none; | |
| 1755 | + | } | |
| 1756 | + | ||
| 1757 | + | /* Label filter (details/summary popup) */ | |
| 1758 | + | .list-header-actions { | |
| 1759 | + | display: flex; | |
| 1760 | + | align-items: center; | |
| 1761 | + | gap: var(--space-2); | |
| 1762 | + | flex-shrink: 0; | |
| 1763 | + | } | |
| 1764 | + | .label-filter { | |
| 1765 | + | position: relative; | |
| 1766 | + | } | |
| 1767 | + | .label-filter-popup { | |
| 1768 | + | position: absolute; | |
| 1769 | + | right: 0; | |
| 1770 | + | top: calc(100% + var(--space-1)); | |
| 1771 | + | z-index: 10; | |
| 1772 | + | background: var(--color-bg); | |
| 1773 | + | border: 1px solid var(--color-border); | |
| 1774 | + | border-radius: var(--radius-lg); | |
| 1775 | + | padding: var(--space-3); | |
| 1776 | + | min-width: 180px; | |
| 1777 | + | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); | |
| 1778 | + | } | |
| 1779 | + | .label-filter-list { | |
| 1780 | + | display: flex; | |
| 1781 | + | flex-direction: column; | |
| 1782 | + | gap: var(--space-2); | |
| 1783 | + | margin-bottom: var(--space-3); | |
| 1784 | + | max-height: 16rem; | |
| 1785 | + | overflow-y: auto; | |
| 1786 | + | } | |
| 1787 | + | .label-filter-item { | |
| 1788 | + | display: flex; | |
| 1789 | + | align-items: center; | |
| 1790 | + | gap: var(--space-2); | |
| 1791 | + | cursor: pointer; | |
| 1792 | + | font-size: var(--text-sm); | |
| 1793 | + | } | |
| 1794 | + | .label-filter-actions { | |
| 1795 | + | display: flex; | |
| 1796 | + | gap: var(--space-2); | |
| 1797 | + | } | |
| 1798 | + | } | |
Msrc/styles/main.css
| @@ -1,2905 +1,7 @@ | |||
|---|---|---|---|
| 1 | - | /* View Transitions */ | |
| 2 | - | @view-transition { | |
| 3 | - | navigation: auto; | |
| 4 | - | } | |
| 1 | + | /* Declare layer order explicitly */ | |
| 2 | + | @layer reset, tokens, layout, components, utilities; | |
| 5 | 3 | ||
| 6 | - | .site-nav { | |
| 7 | - | view-transition-name: site-nav; | |
| 8 | - | } | |
| 9 | - | .repo-header { | |
| 10 | - | view-transition-name: repo-header; | |
| 11 | - | } | |
| 12 | - | .breadcrumb { | |
| 13 | - | view-transition-name: breadcrumb; | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | /* ============================================================ | |
| 17 | - | @layer reset | |
| 18 | - | ============================================================ */ | |
| 19 | - | @layer reset { | |
| 20 | - | *, | |
| 21 | - | *::before, | |
| 22 | - | *::after { | |
| 23 | - | box-sizing: border-box; | |
| 24 | - | margin: 0; | |
| 25 | - | padding: 0; | |
| 26 | - | } | |
| 27 | - | html { | |
| 28 | - | -webkit-text-size-adjust: 100%; | |
| 29 | - | } | |
| 30 | - | body { | |
| 31 | - | min-height: 100dvh; | |
| 32 | - | min-width: 500px; | |
| 33 | - | } | |
| 34 | - | img, | |
| 35 | - | video { | |
| 36 | - | max-width: 100%; | |
| 37 | - | height: auto; | |
| 38 | - | } | |
| 39 | - | button, | |
| 40 | - | input, | |
| 41 | - | textarea, | |
| 42 | - | select { | |
| 43 | - | font: inherit; | |
| 44 | - | } | |
| 45 | - | a { | |
| 46 | - | color: inherit; | |
| 47 | - | } | |
| 48 | - | ul, | |
| 49 | - | ol { | |
| 50 | - | list-style: none; | |
| 51 | - | } | |
| 52 | - | h1, | |
| 53 | - | h2, | |
| 54 | - | h3, | |
| 55 | - | h4, | |
| 56 | - | h5, | |
| 57 | - | h6 { | |
| 58 | - | font-weight: 600; | |
| 59 | - | line-height: 1.2; | |
| 60 | - | } | |
| 61 | - | pre, | |
| 62 | - | code { | |
| 63 | - | font-family: var(--font-mono); | |
| 64 | - | } | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | /* ============================================================ | |
| 68 | - | @layer tokens | |
| 69 | - | ============================================================ */ | |
| 70 | - | @layer tokens { | |
| 71 | - | :root { | |
| 72 | - | /* Colors — light */ | |
| 73 | - | --color-bg: #ffffff; | |
| 74 | - | --color-bg-subtle: #f6f8fa; | |
| 75 | - | --color-bg-inset: #eef0f2; | |
| 76 | - | --color-border: #d0d7de; | |
| 77 | - | --color-border-muted: #e4e7eb; | |
| 78 | - | --color-text: #1f2328; | |
| 79 | - | --color-text-muted: #656d76; | |
| 80 | - | --color-link: #0969da; | |
| 81 | - | --color-link-hover: #0550ae; | |
| 82 | - | ||
| 83 | - | --color-accent: #fd7e14; | |
| 84 | - | --color-accent-bg: #fff3cd; | |
| 85 | - | ||
| 86 | - | --color-success: #1a7f37; | |
| 87 | - | --color-success-bg: #dafbe1; | |
| 88 | - | --color-danger: #cf222e; | |
| 89 | - | --color-danger-bg: #ffebe9; | |
| 90 | - | --color-warning: #9a6700; | |
| 91 | - | --color-warning-bg: #fff8c5; | |
| 92 | - | --color-merged: #8250df; | |
| 93 | - | --color-merged-bg: #fbefff; | |
| 94 | - | ||
| 95 | - | /* Typography */ | |
| 96 | - | --font-sans: | |
| 97 | - | system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| 98 | - | --font-mono: | |
| 99 | - | ui-monospace, "Cascadia Code", "Source Code Pro", monospace; | |
| 100 | - | ||
| 101 | - | --text-xs: 0.75rem; | |
| 102 | - | --text-sm: 0.875rem; | |
| 103 | - | --text-base: 1rem; | |
| 104 | - | --text-lg: 1.125rem; | |
| 105 | - | --text-xl: 1.25rem; | |
| 106 | - | --text-2xl: 1.5rem; | |
| 107 | - | --text-3xl: 1.875rem; | |
| 108 | - | ||
| 109 | - | /* Spacing */ | |
| 110 | - | --space-1: 0.25rem; | |
| 111 | - | --space-2: 0.5rem; | |
| 112 | - | --space-3: 0.75rem; | |
| 113 | - | --space-4: 1rem; | |
| 114 | - | --space-5: 1.25rem; | |
| 115 | - | --space-6: 1.5rem; | |
| 116 | - | --space-8: 2rem; | |
| 117 | - | --space-10: 2.5rem; | |
| 118 | - | --space-12: 3rem; | |
| 119 | - | --space-16: 4rem; | |
| 120 | - | ||
| 121 | - | /* Border radius */ | |
| 122 | - | --radius-sm: 4px; | |
| 123 | - | --radius-md: 6px; | |
| 124 | - | --radius-lg: 8px; | |
| 125 | - | --radius-full: 9999px; | |
| 126 | - | ||
| 127 | - | /* Shadows */ | |
| 128 | - | --shadow-sm: 0 1px 0 rgba(27, 31, 36, 0.04); | |
| 129 | - | --shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15); | |
| 130 | - | --shadow-lg: 0 8px 24px rgba(140, 149, 159, 0.2); | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | @media (prefers-color-scheme: dark) { | |
| 134 | - | :root { | |
| 135 | - | --color-bg: #0d1117; | |
| 136 | - | --color-bg-subtle: #161b22; | |
| 137 | - | --color-bg-inset: #1c2128; | |
| 138 | - | --color-border: #30363d; | |
| 139 | - | --color-border-muted: #21262d; | |
| 140 | - | --color-text: #e6edf3; | |
| 141 | - | --color-text-muted: #7d8590; | |
| 142 | - | --color-link: #58a6ff; | |
| 143 | - | --color-link-hover: #79c0ff; | |
| 144 | - | ||
| 145 | - | --color-accent: #f0883e; | |
| 146 | - | --color-accent-bg: #3d2f00; | |
| 147 | - | ||
| 148 | - | --color-success: #3fb950; | |
| 149 | - | --color-success-bg: #0d2b19; | |
| 150 | - | --color-danger: #f85149; | |
| 151 | - | --color-danger-bg: #2d1419; | |
| 152 | - | --color-warning: #d29922; | |
| 153 | - | --color-warning-bg: #2c2000; | |
| 154 | - | --color-merged: #bc8cff; | |
| 155 | - | --color-merged-bg: #1e0b3e; | |
| 156 | - | } | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | /* Explicit theme overrides — applied via data-theme attribute set by cookie */ | |
| 160 | - | :root[data-theme="dark"] { | |
| 161 | - | --color-bg: #0d1117; | |
| 162 | - | --color-bg-subtle: #161b22; | |
| 163 | - | --color-bg-inset: #1c2128; | |
| 164 | - | --color-border: #30363d; | |
| 165 | - | --color-border-muted: #21262d; | |
| 166 | - | --color-text: #e6edf3; | |
| 167 | - | --color-text-muted: #7d8590; | |
| 168 | - | --color-link: #58a6ff; | |
| 169 | - | --color-link-hover: #79c0ff; | |
| 170 | - | --color-accent: #f0883e; | |
| 171 | - | --color-accent-bg: #3d2f00; | |
| 172 | - | --color-success: #3fb950; | |
| 173 | - | --color-success-bg: #0d2b19; | |
| 174 | - | --color-danger: #f85149; | |
| 175 | - | --color-danger-bg: #2d1419; | |
| 176 | - | --color-warning: #d29922; | |
| 177 | - | --color-warning-bg: #2c2000; | |
| 178 | - | --color-merged: #bc8cff; | |
| 179 | - | --color-merged-bg: #1e0b3e; | |
| 180 | - | } | |
| 181 | - | :root[data-theme="light"] { | |
| 182 | - | --color-bg: #ffffff; | |
| 183 | - | --color-bg-subtle: #f6f8fa; | |
| 184 | - | --color-bg-inset: #eef0f2; | |
| 185 | - | --color-border: #d0d7de; | |
| 186 | - | --color-border-muted: #e4e7eb; | |
| 187 | - | --color-text: #1f2328; | |
| 188 | - | --color-text-muted: #656d76; | |
| 189 | - | --color-link: #0969da; | |
| 190 | - | --color-link-hover: #0550ae; | |
| 191 | - | --color-accent: #fd7e14; | |
| 192 | - | --color-accent-bg: #fff3cd; | |
| 193 | - | --color-success: #1a7f37; | |
| 194 | - | --color-success-bg: #dafbe1; | |
| 195 | - | --color-danger: #cf222e; | |
| 196 | - | --color-danger-bg: #ffebe9; | |
| 197 | - | --color-warning: #9a6700; | |
| 198 | - | --color-warning-bg: #fff8c5; | |
| 199 | - | --color-merged: #8250df; | |
| 200 | - | --color-merged-bg: #fbefff; | |
| 201 | - | } | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | /* ============================================================ | |
| 205 | - | @layer layout | |
| 206 | - | ============================================================ */ | |
| 207 | - | @layer layout { | |
| 208 | - | body { | |
| 209 | - | font-family: var(--font-sans); | |
| 210 | - | font-size: var(--text-base); | |
| 211 | - | color: var(--color-text); | |
| 212 | - | background: var(--color-bg); | |
| 213 | - | display: grid; | |
| 214 | - | grid-template-rows: auto 1fr auto; | |
| 215 | - | min-height: 100dvh; | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | .site-header { | |
| 219 | - | background: var(--color-bg-subtle); | |
| 220 | - | border-bottom: 1px solid var(--color-border); | |
| 221 | - | position: sticky; | |
| 222 | - | top: 0; | |
| 223 | - | z-index: 100; | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | .site-nav { | |
| 227 | - | max-width: 1200px; | |
| 228 | - | margin: 0 auto; | |
| 229 | - | padding: var(--space-3) var(--space-4); | |
| 230 | - | display: flex; | |
| 231 | - | align-items: center; | |
| 232 | - | gap: var(--space-4); | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | .site-logo { | |
| 236 | - | display: flex; | |
| 237 | - | align-items: center; | |
| 238 | - | gap: var(--space-2); | |
| 239 | - | text-decoration: none; | |
| 240 | - | font-weight: 700; | |
| 241 | - | font-size: var(--text-lg); | |
| 242 | - | color: var(--color-text); | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | .logo-icon { | |
| 246 | - | width: 1.6em; | |
| 247 | - | height: 1.6em; | |
| 248 | - | display: block; | |
| 249 | - | flex-shrink: 0; | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | .nav-links { | |
| 253 | - | margin-left: auto; | |
| 254 | - | display: flex; | |
| 255 | - | align-items: center; | |
| 256 | - | gap: var(--space-3); | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | .nav-user { | |
| 260 | - | font-size: var(--text-sm); | |
| 261 | - | color: var(--color-text-muted); | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | .site-main { | |
| 265 | - | padding: var(--space-8) var(--space-4); | |
| 266 | - | min-width: 0; | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | .site-footer { | |
| 270 | - | text-align: center; | |
| 271 | - | padding: var(--space-6) var(--space-4); | |
| 272 | - | font-size: var(--text-sm); | |
| 273 | - | color: var(--color-text-muted); | |
| 274 | - | border-top: 1px solid var(--color-border); | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | .container { | |
| 278 | - | max-width: 1000px; | |
| 279 | - | margin: 0 auto; | |
| 280 | - | overflow-x: clip; | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | .container-narrow { | |
| 284 | - | max-width: 680px; | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | .inline-form { | |
| 288 | - | display: inline; | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | /* --- Two-step delete confirmation --- */ | |
| 292 | - | .confirm-details { | |
| 293 | - | position: relative; | |
| 294 | - | display: inline-block; | |
| 295 | - | } | |
| 296 | - | .confirm-details > summary { | |
| 297 | - | list-style: none; | |
| 298 | - | } | |
| 299 | - | .confirm-details > summary::-webkit-details-marker { | |
| 300 | - | display: none; | |
| 301 | - | } | |
| 302 | - | .confirm-popup { | |
| 303 | - | position: absolute; | |
| 304 | - | right: 0; | |
| 305 | - | top: calc(100% + var(--space-1)); | |
| 306 | - | background: var(--color-bg); | |
| 307 | - | border: 1px solid var(--color-border); | |
| 308 | - | border-radius: var(--radius-md); | |
| 309 | - | padding: var(--space-3) var(--space-4); | |
| 310 | - | display: flex; | |
| 311 | - | align-items: center; | |
| 312 | - | gap: var(--space-3); | |
| 313 | - | white-space: nowrap; | |
| 314 | - | z-index: 100; | |
| 315 | - | font-size: var(--text-sm); | |
| 316 | - | color: var(--color-text-muted); | |
| 317 | - | } | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | /* ============================================================ | |
| 321 | - | @layer components | |
| 322 | - | ============================================================ */ | |
| 323 | - | @layer components { | |
| 324 | - | /* --- Buttons --- */ | |
| 325 | - | .btn { | |
| 326 | - | display: inline-flex; | |
| 327 | - | align-items: center; | |
| 328 | - | gap: var(--space-2); | |
| 329 | - | padding: var(--space-2) var(--space-4); | |
| 330 | - | border: 1px solid var(--color-border); | |
| 331 | - | border-radius: var(--radius-md); | |
| 332 | - | font-size: var(--text-sm); | |
| 333 | - | font-weight: 500; | |
| 334 | - | text-decoration: none; | |
| 335 | - | cursor: pointer; | |
| 336 | - | background: var(--color-bg-subtle); | |
| 337 | - | color: var(--color-text); | |
| 338 | - | transition: | |
| 339 | - | background 0.1s, | |
| 340 | - | border-color 0.1s; | |
| 341 | - | white-space: nowrap; | |
| 342 | - | } | |
| 343 | - | .btn:hover { | |
| 344 | - | background: var(--color-bg-inset); | |
| 345 | - | border-color: var(--color-border); | |
| 346 | - | } | |
| 347 | - | .btn:disabled { | |
| 348 | - | opacity: 0.5; | |
| 349 | - | cursor: not-allowed; | |
| 350 | - | } | |
| 351 | - | .btn-primary { | |
| 352 | - | background: var(--color-accent); | |
| 353 | - | border-color: transparent; | |
| 354 | - | color: #fff; | |
| 355 | - | } | |
| 356 | - | .btn-primary:hover { | |
| 357 | - | background: color-mix(in srgb, var(--color-accent) 82%, #000); | |
| 358 | - | } | |
| 359 | - | .btn-secondary { | |
| 360 | - | background: var(--color-bg-subtle); | |
| 361 | - | border-color: var(--color-border); | |
| 362 | - | } | |
| 363 | - | .btn-sm { | |
| 364 | - | padding: var(--space-1) var(--space-3); | |
| 365 | - | font-size: var(--text-xs); | |
| 366 | - | } | |
| 367 | - | .btn-block { | |
| 368 | - | width: 100%; | |
| 369 | - | justify-content: center; | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | /* --- Forms --- */ | |
| 373 | - | .form-group { | |
| 374 | - | display: flex; | |
| 375 | - | flex-direction: column; | |
| 376 | - | gap: var(--space-2); | |
| 377 | - | margin-bottom: var(--space-4); | |
| 378 | - | } | |
| 379 | - | .form-group label, | |
| 380 | - | .form-group .form-group-label { | |
| 381 | - | font-size: var(--text-sm); | |
| 382 | - | font-weight: 500; | |
| 383 | - | color: var(--color-text); | |
| 384 | - | } | |
| 385 | - | .form-group input:not([type="file"]):not([type="checkbox"]), | |
| 386 | - | .form-group textarea, | |
| 387 | - | .form-group select { | |
| 388 | - | padding: var(--space-2) var(--space-3); | |
| 389 | - | border: 1px solid var(--color-border); | |
| 390 | - | border-radius: var(--radius-md); | |
| 391 | - | background: var(--color-bg); | |
| 392 | - | color: var(--color-text); | |
| 393 | - | font-size: var(--text-sm); | |
| 394 | - | } | |
| 395 | - | .form-group input:not([type="file"]):not([type="checkbox"]):focus, | |
| 396 | - | .form-group textarea:focus { | |
| 397 | - | outline: 2px solid var(--color-accent); | |
| 398 | - | outline-offset: -1px; | |
| 399 | - | border-color: var(--color-accent); | |
| 400 | - | } | |
| 401 | - | .form-group input[type="file"] { | |
| 402 | - | font-size: var(--text-sm); | |
| 403 | - | color: var(--color-text); | |
| 404 | - | padding: 0; | |
| 405 | - | border: none; | |
| 406 | - | background: none; | |
| 407 | - | } | |
| 408 | - | .form-group textarea { | |
| 409 | - | resize: vertical; | |
| 410 | - | min-height: 120px; | |
| 411 | - | } | |
| 412 | - | .form-success { | |
| 413 | - | background: var(--color-success-bg); | |
| 414 | - | color: var(--color-success); | |
| 415 | - | border: 1px solid var(--color-success); | |
| 416 | - | border-radius: var(--radius-md); | |
| 417 | - | padding: var(--space-3) var(--space-4); | |
| 418 | - | font-size: var(--text-sm); | |
| 419 | - | margin-bottom: var(--space-4); | |
| 420 | - | } | |
| 421 | - | .form-hint { | |
| 422 | - | font-size: var(--text-sm); | |
| 423 | - | color: var(--color-text-muted); | |
| 424 | - | margin: 0; | |
| 425 | - | } | |
| 426 | - | .form-error { | |
| 427 | - | background: var(--color-danger-bg); | |
| 428 | - | color: var(--color-danger); | |
| 429 | - | border: 1px solid var(--color-danger); | |
| 430 | - | border-radius: var(--radius-md); | |
| 431 | - | padding: var(--space-3) var(--space-4); | |
| 432 | - | font-size: var(--text-sm); | |
| 433 | - | margin-bottom: var(--space-4); | |
| 434 | - | } | |
| 435 | - | .form-actions { | |
| 436 | - | display: flex; | |
| 437 | - | gap: var(--space-3); | |
| 438 | - | align-items: center; | |
| 439 | - | flex-wrap: wrap; | |
| 440 | - | } | |
| 441 | - | .form-card { | |
| 442 | - | background: var(--color-bg-subtle); | |
| 443 | - | border: 1px solid var(--color-border); | |
| 444 | - | border-radius: var(--radius-lg); | |
| 445 | - | padding: var(--space-6); | |
| 446 | - | margin-top: var(--space-4); | |
| 447 | - | } | |
| 448 | - | .checkbox-label { | |
| 449 | - | display: flex; | |
| 450 | - | align-items: center; | |
| 451 | - | gap: var(--space-2); | |
| 452 | - | cursor: pointer; | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | /* --- Page header --- */ | |
| 456 | - | .page-header { | |
| 457 | - | display: flex; | |
| 458 | - | align-items: center; | |
| 459 | - | justify-content: space-between; | |
| 460 | - | margin-bottom: var(--space-6); | |
| 461 | - | gap: var(--space-4); | |
| 462 | - | } | |
| 463 | - | .page-title { | |
| 464 | - | font-size: var(--text-2xl); | |
| 465 | - | color: var(--color-text); | |
| 466 | - | margin-bottom: var(--space-4); | |
| 467 | - | } | |
| 468 | - | .page-title a { | |
| 469 | - | text-decoration: none; | |
| 470 | - | color: var(--color-link); | |
| 471 | - | } | |
| 472 | - | .page-title a:hover { | |
| 473 | - | text-decoration: underline; | |
| 474 | - | } | |
| 475 | - | .section-title { | |
| 476 | - | font-size: var(--text-lg); | |
| 477 | - | margin-bottom: var(--space-4); | |
| 478 | - | padding-bottom: var(--space-2); | |
| 479 | - | border-bottom: 1px solid var(--color-border); | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | /* --- Auth --- */ | |
| 483 | - | .auth-container { | |
| 484 | - | max-width: 400px; | |
| 485 | - | margin: var(--space-12) auto; | |
| 486 | - | } | |
| 487 | - | .auth-container .page-title { | |
| 488 | - | margin-bottom: var(--space-6); | |
| 489 | - | } | |
| 490 | - | .auth-form { | |
| 491 | - | margin-bottom: var(--space-4); | |
| 492 | - | } | |
| 493 | - | .auth-footer { | |
| 494 | - | text-align: center; | |
| 495 | - | font-size: var(--text-sm); | |
| 496 | - | color: var(--color-text-muted); | |
| 497 | - | margin-top: var(--space-4); | |
| 498 | - | } | |
| 499 | - | .passkey-btn { | |
| 500 | - | display: none; | |
| 501 | - | margin-top: var(--space-3); | |
| 502 | - | } | |
| 503 | - | ||
| 504 | - | /* --- Repo header --- */ | |
| 505 | - | .repo-header { | |
| 506 | - | margin-bottom: var(--space-2); | |
| 507 | - | } | |
| 508 | - | .repo-title-row { | |
| 509 | - | display: flex; | |
| 510 | - | align-items: center; | |
| 511 | - | gap: var(--space-3); | |
| 512 | - | flex-wrap: wrap; | |
| 513 | - | } | |
| 514 | - | .repo-title-row .page-title { | |
| 515 | - | margin-bottom: 0; | |
| 516 | - | } | |
| 517 | - | .repo-header-title { | |
| 518 | - | display: flex; | |
| 519 | - | align-items: center; | |
| 520 | - | gap: var(--space-3); | |
| 521 | - | flex-wrap: wrap; | |
| 522 | - | } | |
| 523 | - | .repo-description { | |
| 524 | - | color: var(--color-text-muted); | |
| 525 | - | margin-top: var(--space-2); | |
| 526 | - | } | |
| 527 | - | .repo-header-meta { | |
| 528 | - | display: flex; | |
| 529 | - | align-items: center; | |
| 530 | - | gap: var(--space-4); | |
| 531 | - | margin-bottom: var(--space-3); | |
| 532 | - | font-size: var(--text-sm); | |
| 533 | - | color: var(--color-text-muted); | |
| 534 | - | } | |
| 535 | - | .icon { | |
| 536 | - | font-style: normal; | |
| 537 | - | } | |
| 538 | - | ||
| 539 | - | /* --- Branch selector --- */ | |
| 540 | - | .branch-selector { | |
| 541 | - | display: inline-flex; | |
| 542 | - | align-items: center; | |
| 543 | - | gap: var(--space-2); | |
| 544 | - | } | |
| 545 | - | .branch-selector-icon { | |
| 546 | - | color: var(--color-text-muted); | |
| 547 | - | font-size: var(--text-sm); | |
| 548 | - | user-select: none; | |
| 549 | - | } | |
| 550 | - | .branch-select { | |
| 551 | - | padding: var(--space-1) var(--space-2); | |
| 552 | - | padding-right: var(--space-5); | |
| 553 | - | border: 1px solid var(--color-border); | |
| 554 | - | border-radius: var(--radius-md); | |
| 555 | - | background: var(--color-bg-subtle); | |
| 556 | - | font-size: var(--text-sm); | |
| 557 | - | font-family: var(--font-mono); | |
| 558 | - | color: var(--color-text); | |
| 559 | - | cursor: pointer; | |
| 560 | - | appearance: auto; | |
| 561 | - | max-width: 180px; | |
| 562 | - | } | |
| 563 | - | .branch-select:hover { | |
| 564 | - | border-color: var(--color-text-muted); | |
| 565 | - | } | |
| 566 | - | .branch-select:focus { | |
| 567 | - | outline: 2px solid var(--color-accent); | |
| 568 | - | outline-offset: -1px; | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | /* toolbar row above file tree */ | |
| 572 | - | .tree-toolbar { | |
| 573 | - | display: flex; | |
| 574 | - | align-items: center; | |
| 575 | - | justify-content: space-between; | |
| 576 | - | margin-bottom: var(--space-3); | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | /* commits-header flex row */ | |
| 580 | - | .commits-header { | |
| 581 | - | display: flex; | |
| 582 | - | align-items: center; | |
| 583 | - | gap: var(--space-4); | |
| 584 | - | margin-bottom: var(--space-4); | |
| 585 | - | flex-wrap: wrap; | |
| 586 | - | } | |
| 587 | - | .commits-header .section-title { | |
| 588 | - | margin-bottom: 0; | |
| 589 | - | } | |
| 590 | - | ||
| 591 | - | /* --- Repo nav bar (tabs + private badge) --- */ | |
| 592 | - | .repo-nav-bar { | |
| 593 | - | display: flex; | |
| 594 | - | align-items: center; | |
| 595 | - | justify-content: space-between; | |
| 596 | - | border-bottom: 1px solid var(--color-border); | |
| 597 | - | margin-bottom: var(--space-6); | |
| 598 | - | gap: var(--space-4); | |
| 599 | - | } | |
| 600 | - | ||
| 601 | - | /* --- Repo tabs --- */ | |
| 602 | - | .repo-tabs { | |
| 603 | - | display: flex; | |
| 604 | - | gap: 0; | |
| 605 | - | flex: 1; | |
| 606 | - | overflow-x: auto; | |
| 607 | - | overflow-y: hidden; | |
| 608 | - | } | |
| 609 | - | .repo-tab { | |
| 610 | - | padding: var(--space-3) var(--space-4); | |
| 611 | - | font-size: var(--text-sm); | |
| 612 | - | text-decoration: none; | |
| 613 | - | color: var(--color-text-muted); | |
| 614 | - | border-bottom: 2px solid transparent; | |
| 615 | - | margin-bottom: -1px; | |
| 616 | - | transition: color 0.1s; | |
| 617 | - | } | |
| 618 | - | .repo-tab:hover { | |
| 619 | - | color: var(--color-text); | |
| 620 | - | } | |
| 621 | - | .repo-tab.active { | |
| 622 | - | color: var(--color-text); | |
| 623 | - | font-weight: 600; | |
| 624 | - | border-bottom-color: var(--color-accent); | |
| 625 | - | } | |
| 626 | - | /* Align active indicator with nav-bar border */ | |
| 627 | - | .repo-tab { | |
| 628 | - | margin-bottom: -1px; | |
| 629 | - | } | |
| 630 | - | ||
| 631 | - | /* --- Repo list --- */ | |
| 632 | - | .repo-list { | |
| 633 | - | display: flex; | |
| 634 | - | flex-direction: column; | |
| 635 | - | gap: var(--space-2); | |
| 636 | - | } | |
| 637 | - | .repo-card { | |
| 638 | - | display: flex; | |
| 639 | - | align-items: flex-start; | |
| 640 | - | justify-content: space-between; | |
| 641 | - | padding: var(--space-4) var(--space-5); | |
| 642 | - | border: 1px solid var(--color-border); | |
| 643 | - | border-radius: var(--radius-lg); | |
| 644 | - | background: var(--color-bg); | |
| 645 | - | gap: var(--space-4); | |
| 646 | - | transition: border-color 0.15s; | |
| 647 | - | } | |
| 648 | - | .repo-card:hover { | |
| 649 | - | border-color: var(--color-accent); | |
| 650 | - | } | |
| 651 | - | .repo-card-main { | |
| 652 | - | flex: 1; | |
| 653 | - | min-width: 0; | |
| 654 | - | } | |
| 655 | - | .repo-card-title { | |
| 656 | - | display: flex; | |
| 657 | - | align-items: center; | |
| 658 | - | gap: var(--space-2); | |
| 659 | - | margin-bottom: var(--space-1); | |
| 660 | - | } | |
| 661 | - | .repo-name { | |
| 662 | - | font-size: var(--text-lg); | |
| 663 | - | font-weight: 600; | |
| 664 | - | color: var(--color-link); | |
| 665 | - | text-decoration: none; | |
| 666 | - | word-break: break-all; | |
| 667 | - | } | |
| 668 | - | .repo-name:hover { | |
| 669 | - | text-decoration: underline; | |
| 670 | - | } | |
| 671 | - | .repo-card-meta { | |
| 672 | - | display: flex; | |
| 673 | - | flex-direction: column; | |
| 674 | - | align-items: flex-end; | |
| 675 | - | gap: var(--space-1); | |
| 676 | - | font-size: var(--text-xs); | |
| 677 | - | color: var(--color-text-muted); | |
| 678 | - | white-space: nowrap; | |
| 679 | - | } | |
| 680 | - | .repo-date { | |
| 681 | - | color: var(--color-text-muted); | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | /* --- Badges --- */ | |
| 685 | - | .badge { | |
| 686 | - | display: inline-flex; | |
| 687 | - | align-items: center; | |
| 688 | - | padding: 1px var(--space-2); | |
| 689 | - | border-radius: var(--radius-full); | |
| 690 | - | font-size: var(--text-xs); | |
| 691 | - | font-weight: 500; | |
| 692 | - | border: 1px solid; | |
| 693 | - | text-decoration: none; | |
| 694 | - | } | |
| 695 | - | .badge-private { | |
| 696 | - | color: var(--color-warning); | |
| 697 | - | border-color: var(--color-warning); | |
| 698 | - | background: var(--color-warning-bg); | |
| 699 | - | } | |
| 700 | - | .badge-pinned { | |
| 701 | - | color: var(--color-accent); | |
| 702 | - | border-color: var(--color-accent); | |
| 703 | - | background: var(--color-accent-bg); | |
| 704 | - | } | |
| 705 | - | .issue-badge, | |
| 706 | - | .patch-badge { | |
| 707 | - | display: inline-flex; | |
| 708 | - | align-items: center; | |
| 709 | - | padding: var(--space-1) var(--space-3); | |
| 710 | - | border-radius: var(--radius-full); | |
| 711 | - | font-size: var(--text-xs); | |
| 712 | - | font-weight: 500; | |
| 713 | - | text-transform: capitalize; | |
| 714 | - | } | |
| 715 | - | .issue-badge.open, | |
| 716 | - | .patch-badge.open { | |
| 717 | - | background: var(--color-success-bg); | |
| 718 | - | color: var(--color-success); | |
| 719 | - | } | |
| 720 | - | .issue-badge.closed, | |
| 721 | - | .patch-badge.closed { | |
| 722 | - | background: var(--color-danger-bg); | |
| 723 | - | color: var(--color-danger); | |
| 724 | - | } | |
| 725 | - | .issue-badge.completed { | |
| 726 | - | background: var(--color-merged-bg); | |
| 727 | - | color: var(--color-merged); | |
| 728 | - | } | |
| 729 | - | .patch-badge.merged { | |
| 730 | - | background: var(--color-merged-bg); | |
| 731 | - | color: var(--color-merged); | |
| 732 | - | } | |
| 733 | - | .sig-badge { | |
| 734 | - | display: inline-flex; | |
| 735 | - | align-items: center; | |
| 736 | - | padding: var(--space-1) var(--space-3); | |
| 737 | - | border-radius: var(--radius-full); | |
| 738 | - | font-size: var(--text-xs); | |
| 739 | - | font-weight: 500; | |
| 740 | - | } | |
| 741 | - | .sig-badge.verified { | |
| 742 | - | background: var(--color-success-bg); | |
| 743 | - | color: var(--color-success); | |
| 744 | - | } | |
| 745 | - | .sig-badge.unverified { | |
| 746 | - | background: var(--color-danger-bg); | |
| 747 | - | color: var(--color-danger); | |
| 748 | - | } | |
| 749 | - | ||
| 750 | - | /* --- Empty state --- */ | |
| 751 | - | .empty-state { | |
| 752 | - | text-align: center; | |
| 753 | - | padding: var(--space-16) var(--space-4); | |
| 754 | - | color: var(--color-text-muted); | |
| 755 | - | } | |
| 756 | - | .empty-state p { | |
| 757 | - | margin-bottom: var(--space-4); | |
| 758 | - | font-size: var(--text-lg); | |
| 759 | - | } | |
| 760 | - | ||
| 761 | - | /* --- Breadcrumb --- */ | |
| 762 | - | .breadcrumb { | |
| 763 | - | display: flex; | |
| 764 | - | align-items: center; | |
| 765 | - | flex-wrap: wrap; | |
| 766 | - | gap: var(--space-1); | |
| 767 | - | font-size: var(--text-sm); | |
| 768 | - | margin-bottom: var(--space-4); | |
| 769 | - | } | |
| 770 | - | .breadcrumb a { | |
| 771 | - | color: var(--color-link); | |
| 772 | - | text-decoration: none; | |
| 773 | - | } | |
| 774 | - | .breadcrumb a:hover { | |
| 775 | - | text-decoration: underline; | |
| 776 | - | } | |
| 777 | - | .breadcrumb-sep { | |
| 778 | - | color: var(--color-text-muted); | |
| 779 | - | } | |
| 780 | - | .breadcrumb-current { | |
| 781 | - | color: var(--color-text-muted); | |
| 782 | - | } | |
| 783 | - | ||
| 784 | - | /* --- File tree --- */ | |
| 785 | - | .file-tree { | |
| 786 | - | width: 100%; | |
| 787 | - | border: 1px solid var(--color-border); | |
| 788 | - | border-radius: var(--radius-lg); | |
| 789 | - | border-collapse: collapse; | |
| 790 | - | overflow: hidden; | |
| 791 | - | margin-bottom: var(--space-6); | |
| 792 | - | } | |
| 793 | - | .file-tree-row { | |
| 794 | - | border-bottom: 1px solid var(--color-border-muted); | |
| 795 | - | } | |
| 796 | - | .file-tree-row:last-child { | |
| 797 | - | border-bottom: none; | |
| 798 | - | } | |
| 799 | - | .file-tree-row:hover { | |
| 800 | - | background: var(--color-bg-subtle); | |
| 801 | - | } | |
| 802 | - | .file-icon, | |
| 803 | - | .file-name, | |
| 804 | - | .file-size { | |
| 805 | - | padding: var(--space-2) var(--space-3); | |
| 806 | - | font-size: var(--text-sm); | |
| 807 | - | vertical-align: middle; | |
| 808 | - | } | |
| 809 | - | .file-icon { | |
| 810 | - | width: 36px; | |
| 811 | - | padding-right: var(--space-1); | |
| 812 | - | text-align: center; | |
| 813 | - | line-height: 0; | |
| 814 | - | } | |
| 815 | - | .file-name { | |
| 816 | - | width: 100%; | |
| 817 | - | } | |
| 818 | - | .file-name a { | |
| 819 | - | color: var(--color-link); | |
| 820 | - | text-decoration: none; | |
| 821 | - | } | |
| 822 | - | .file-name a:hover { | |
| 823 | - | text-decoration: underline; | |
| 824 | - | } | |
| 825 | - | .file-name a.file-name-dir { | |
| 826 | - | font-weight: 500; | |
| 827 | - | } | |
| 828 | - | .file-size { | |
| 829 | - | text-align: right; | |
| 830 | - | color: var(--color-text-muted); | |
| 831 | - | font-size: var(--text-xs); | |
| 832 | - | white-space: nowrap; | |
| 833 | - | padding-right: var(--space-4); | |
| 834 | - | } | |
| 835 | - | .file-icon-dir { | |
| 836 | - | color: var(--color-link); | |
| 837 | - | } | |
| 838 | - | .file-icon-file { | |
| 839 | - | color: var(--color-text-muted); | |
| 840 | - | } | |
| 841 | - | .tree-icon { | |
| 842 | - | display: inline-block; | |
| 843 | - | vertical-align: text-bottom; | |
| 844 | - | } | |
| 845 | - | .file-tree-row-up .file-name a { | |
| 846 | - | color: var(--color-text-muted); | |
| 847 | - | } | |
| 848 | - | .file-tree-row-up .file-name a:hover { | |
| 849 | - | color: var(--color-link); | |
| 850 | - | } | |
| 851 | - | ||
| 852 | - | /* --- File blob --- */ | |
| 853 | - | .file-blob-header { | |
| 854 | - | display: flex; | |
| 855 | - | align-items: center; | |
| 856 | - | justify-content: space-between; | |
| 857 | - | padding: var(--space-3) var(--space-4); | |
| 858 | - | background: var(--color-bg-subtle); | |
| 859 | - | border: 1px solid var(--color-border); | |
| 860 | - | border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| 861 | - | margin-top: var(--space-4); | |
| 862 | - | } | |
| 863 | - | .file-blob-name { | |
| 864 | - | font-size: var(--text-sm); | |
| 865 | - | font-weight: 500; | |
| 866 | - | } | |
| 867 | - | .file-blob-actions { | |
| 868 | - | display: flex; | |
| 869 | - | align-items: center; | |
| 870 | - | gap: var(--space-2); | |
| 871 | - | } | |
| 872 | - | .file-blob-body { | |
| 873 | - | border: 1px solid var(--color-border); | |
| 874 | - | border-top: none; | |
| 875 | - | border-radius: 0 0 var(--radius-lg) var(--radius-lg); | |
| 876 | - | overflow: auto; | |
| 877 | - | } | |
| 878 | - | .file-edit-textarea { | |
| 879 | - | display: block; | |
| 880 | - | width: 100%; | |
| 881 | - | box-sizing: border-box; | |
| 882 | - | font-family: var(--font-mono); | |
| 883 | - | font-size: var(--text-sm); | |
| 884 | - | line-height: 1.6; | |
| 885 | - | padding: var(--space-4); | |
| 886 | - | background: var(--color-bg); | |
| 887 | - | color: var(--color-text); | |
| 888 | - | border: none; | |
| 889 | - | resize: vertical; | |
| 890 | - | min-height: 400px; | |
| 891 | - | } | |
| 892 | - | .file-edit-textarea:focus { | |
| 893 | - | outline: 2px solid var(--color-accent); | |
| 894 | - | outline-offset: -2px; | |
| 895 | - | } | |
| 896 | - | /* Blob line-number table */ | |
| 897 | - | .blob-table { | |
| 898 | - | min-width: 100%; | |
| 899 | - | border-collapse: collapse; | |
| 900 | - | font-family: var(--font-mono); | |
| 901 | - | font-size: var(--text-sm); | |
| 902 | - | line-height: 1.6; | |
| 903 | - | background-color: var(--shiki-light-bg, #fff); | |
| 904 | - | color: var(--shiki-light, inherit); | |
| 905 | - | } | |
| 906 | - | .blob-ln { | |
| 907 | - | width: 1%; | |
| 908 | - | min-width: 48px; | |
| 909 | - | text-align: right; | |
| 910 | - | padding: 0 var(--space-3); | |
| 911 | - | color: var(--color-text-muted); | |
| 912 | - | border-right: 1px solid var(--color-border-muted); | |
| 913 | - | user-select: none; | |
| 914 | - | white-space: nowrap; | |
| 915 | - | vertical-align: top; | |
| 916 | - | } | |
| 917 | - | .blob-ln a { | |
| 918 | - | color: inherit; | |
| 919 | - | text-decoration: none; | |
| 920 | - | } | |
| 921 | - | .blob-ln a:hover { | |
| 922 | - | color: var(--color-link); | |
| 923 | - | } | |
| 924 | - | .blob-code { | |
| 925 | - | padding: 0 var(--space-4); | |
| 926 | - | white-space: pre; | |
| 927 | - | overflow: visible; | |
| 928 | - | vertical-align: top; | |
| 929 | - | } | |
| 930 | - | .blob-table tr:target { | |
| 931 | - | background: color-mix(in srgb, #ddf4ff 60%, transparent); | |
| 932 | - | } | |
| 933 | - | .blob-table tr:target .blob-ln { | |
| 934 | - | background: color-mix(in srgb, #ddf4ff 80%, transparent); | |
| 935 | - | } | |
| 936 | - | .file-download-notice { | |
| 937 | - | padding: var(--space-8); | |
| 938 | - | text-align: center; | |
| 939 | - | color: var(--color-text-muted); | |
| 940 | - | } | |
| 941 | - | .file-download-notice p { | |
| 942 | - | margin-bottom: var(--space-4); | |
| 943 | - | } | |
| 944 | - | .file-media { | |
| 945 | - | padding: var(--space-4); | |
| 946 | - | display: flex; | |
| 947 | - | justify-content: center; | |
| 948 | - | background: var(--color-canvas-subtle); | |
| 949 | - | } | |
| 950 | - | .file-media-img { | |
| 951 | - | max-width: 100%; | |
| 952 | - | height: auto; | |
| 953 | - | display: block; | |
| 954 | - | } | |
| 955 | - | .file-media-audio { | |
| 956 | - | width: 100%; | |
| 957 | - | max-width: 600px; | |
| 958 | - | } | |
| 959 | - | .file-media-video { | |
| 960 | - | max-width: 100%; | |
| 961 | - | max-height: 80vh; | |
| 962 | - | } | |
| 963 | - | ||
| 964 | - | /* --- Commit list --- */ | |
| 965 | - | .commit-list { | |
| 966 | - | display: flex; | |
| 967 | - | flex-direction: column; | |
| 968 | - | } | |
| 969 | - | .commit-item { | |
| 970 | - | display: flex; | |
| 971 | - | flex-direction: column; | |
| 972 | - | padding: var(--space-3) 0; | |
| 973 | - | border-bottom: 1px solid var(--color-border-muted); | |
| 974 | - | gap: var(--space-2); | |
| 975 | - | } | |
| 976 | - | .commit-item:last-child { | |
| 977 | - | border-bottom: none; | |
| 978 | - | } | |
| 979 | - | .commit-subject { | |
| 980 | - | color: var(--color-text); | |
| 981 | - | text-decoration: none; | |
| 982 | - | font-size: var(--text-sm); | |
| 983 | - | font-weight: 500; | |
| 984 | - | } | |
| 985 | - | .commit-subject:hover { | |
| 986 | - | color: var(--color-link); | |
| 987 | - | } | |
| 988 | - | .commit-meta { | |
| 989 | - | display: flex; | |
| 990 | - | align-items: center; | |
| 991 | - | justify-content: space-between; | |
| 992 | - | gap: var(--space-3); | |
| 993 | - | font-size: var(--text-xs); | |
| 994 | - | color: var(--color-text-muted); | |
| 995 | - | } | |
| 996 | - | .commit-meta-right { | |
| 997 | - | display: flex; | |
| 998 | - | align-items: center; | |
| 999 | - | gap: var(--space-3); | |
| 1000 | - | white-space: nowrap; | |
| 1001 | - | } | |
| 1002 | - | .commit-hash { | |
| 1003 | - | font-family: var(--font-mono); | |
| 1004 | - | color: var(--color-link); | |
| 1005 | - | text-decoration: none; | |
| 1006 | - | background: var(--color-bg-inset); | |
| 1007 | - | padding: 1px var(--space-2); | |
| 1008 | - | border-radius: var(--radius-sm); | |
| 1009 | - | } | |
| 1010 | - | .commit-hash:hover { | |
| 1011 | - | text-decoration: underline; | |
| 1012 | - | } | |
| 1013 | - | .mono { | |
| 1014 | - | font-family: var(--font-mono); | |
| 1015 | - | } | |
| 1016 | - | ||
| 1017 | - | /* --- Commit detail viewer --- */ | |
| 1018 | - | .commit-page-top { | |
| 1019 | - | display: flex; | |
| 1020 | - | align-items: center; | |
| 1021 | - | justify-content: space-between; | |
| 1022 | - | margin-bottom: var(--space-4); | |
| 1023 | - | } | |
| 1024 | - | .commit-card { | |
| 1025 | - | border: 1px solid var(--color-border); | |
| 1026 | - | border-radius: var(--radius-lg); | |
| 1027 | - | margin-bottom: var(--space-4); | |
| 1028 | - | overflow: hidden; | |
| 1029 | - | } | |
| 1030 | - | .commit-card-subject { | |
| 1031 | - | font-size: var(--text-xl); | |
| 1032 | - | font-weight: 600; | |
| 1033 | - | padding: var(--space-5) var(--space-6); | |
| 1034 | - | border-bottom: 1px solid var(--color-border); | |
| 1035 | - | line-height: 1.3; | |
| 1036 | - | } | |
| 1037 | - | .commit-card-subject-empty { | |
| 1038 | - | color: var(--color-text-muted); | |
| 1039 | - | font-style: italic; | |
| 1040 | - | font-weight: 400; | |
| 1041 | - | font-size: var(--text-sm); | |
| 1042 | - | } | |
| 1043 | - | .commit-card-body { | |
| 1044 | - | padding: var(--space-4) var(--space-6); | |
| 1045 | - | font-family: inherit; | |
| 1046 | - | font-size: var(--text-sm); | |
| 1047 | - | color: var(--color-text-muted); | |
| 1048 | - | border-bottom: 1px solid var(--color-border); | |
| 1049 | - | overflow-x: auto; | |
| 1050 | - | } | |
| 1051 | - | .commit-card-meta { | |
| 1052 | - | padding: var(--space-4) var(--space-6); | |
| 1053 | - | display: flex; | |
| 1054 | - | flex-direction: column; | |
| 1055 | - | gap: var(--space-2); | |
| 1056 | - | background: var(--color-bg-subtle); | |
| 1057 | - | } | |
| 1058 | - | .commit-card-meta-row { | |
| 1059 | - | display: flex; | |
| 1060 | - | align-items: baseline; | |
| 1061 | - | gap: var(--space-3); | |
| 1062 | - | font-size: var(--text-sm); | |
| 1063 | - | flex-wrap: wrap; | |
| 1064 | - | } | |
| 1065 | - | .commit-meta-label { | |
| 1066 | - | font-weight: 600; | |
| 1067 | - | color: var(--color-text-muted); | |
| 1068 | - | min-width: 5rem; | |
| 1069 | - | font-size: var(--text-xs); | |
| 1070 | - | text-transform: uppercase; | |
| 1071 | - | letter-spacing: 0.04em; | |
| 1072 | - | } | |
| 1073 | - | .commit-sha-full { | |
| 1074 | - | font-size: var(--text-xs); | |
| 1075 | - | background: var(--color-bg-inset); | |
| 1076 | - | padding: 2px var(--space-2); | |
| 1077 | - | border-radius: var(--radius-sm); | |
| 1078 | - | word-break: break-all; | |
| 1079 | - | } | |
| 1080 | - | .commit-stats-bar { | |
| 1081 | - | font-size: var(--text-sm); | |
| 1082 | - | color: var(--color-text-muted); | |
| 1083 | - | margin-bottom: var(--space-4); | |
| 1084 | - | } | |
| 1085 | - | ||
| 1086 | - | /* --- Commit layout (sidebar + diffs) --- */ | |
| 1087 | - | .commit-layout { | |
| 1088 | - | display: grid; | |
| 1089 | - | grid-template-columns: 1fr; | |
| 1090 | - | gap: var(--space-5); | |
| 1091 | - | align-items: start; | |
| 1092 | - | } | |
| 1093 | - | .commit-diffs { | |
| 1094 | - | min-width: 0; | |
| 1095 | - | } | |
| 1096 | - | @media (min-width: 900px) { | |
| 1097 | - | .commit-layout { | |
| 1098 | - | grid-template-columns: 220px 1fr; | |
| 1099 | - | } | |
| 1100 | - | .commit-layout:has(.file-nav-details:not([open])) { | |
| 1101 | - | grid-template-columns: auto 1fr; | |
| 1102 | - | } | |
| 1103 | - | } | |
| 1104 | - | ||
| 1105 | - | /* --- File nav sidebar --- */ | |
| 1106 | - | .commit-file-nav { | |
| 1107 | - | position: sticky; | |
| 1108 | - | top: calc(56px + var(--space-4)); | |
| 1109 | - | max-height: calc(100vh - 140px); | |
| 1110 | - | overflow-y: auto; | |
| 1111 | - | } | |
| 1112 | - | .file-nav-details { | |
| 1113 | - | border: 1px solid var(--color-border); | |
| 1114 | - | border-radius: var(--radius-lg); | |
| 1115 | - | background: var(--color-bg-subtle); | |
| 1116 | - | overflow: hidden; | |
| 1117 | - | } | |
| 1118 | - | .file-nav-toggle { | |
| 1119 | - | display: flex; | |
| 1120 | - | align-items: center; | |
| 1121 | - | gap: var(--space-2); | |
| 1122 | - | padding: var(--space-3) var(--space-4); | |
| 1123 | - | font-size: var(--text-sm); | |
| 1124 | - | font-weight: 500; | |
| 1125 | - | cursor: pointer; | |
| 1126 | - | list-style: none; | |
| 1127 | - | user-select: none; | |
| 1128 | - | border-bottom: 1px solid var(--color-border); | |
| 1129 | - | } | |
| 1130 | - | .file-nav-toggle::-webkit-details-marker { | |
| 1131 | - | display: none; | |
| 1132 | - | } | |
| 1133 | - | .file-nav-details:not([open]) .file-nav-toggle { | |
| 1134 | - | border-bottom: none; | |
| 1135 | - | } | |
| 1136 | - | .file-nav-toggle-icon { | |
| 1137 | - | font-size: 1em; | |
| 1138 | - | transition: transform 0.15s; | |
| 1139 | - | } | |
| 1140 | - | .file-nav-details:not([open]) .file-nav-toggle-icon { | |
| 1141 | - | transform: rotate(-90deg); | |
| 1142 | - | } | |
| 1143 | - | .file-nav-list { | |
| 1144 | - | padding: var(--space-1) 0; | |
| 1145 | - | list-style: none; | |
| 1146 | - | } | |
| 1147 | - | .file-nav-item { | |
| 1148 | - | display: flex; | |
| 1149 | - | align-items: center; | |
| 1150 | - | gap: var(--space-2); | |
| 1151 | - | padding: var(--space-1) var(--space-3); | |
| 1152 | - | font-size: var(--text-xs); | |
| 1153 | - | text-decoration: none; | |
| 1154 | - | color: var(--color-text); | |
| 1155 | - | border-radius: 0; | |
| 1156 | - | overflow: hidden; | |
| 1157 | - | } | |
| 1158 | - | .file-nav-item:hover { | |
| 1159 | - | background: var(--color-bg-inset); | |
| 1160 | - | } | |
| 1161 | - | .file-nav-status { | |
| 1162 | - | flex-shrink: 0; | |
| 1163 | - | width: 16px; | |
| 1164 | - | height: 16px; | |
| 1165 | - | display: inline-flex; | |
| 1166 | - | align-items: center; | |
| 1167 | - | justify-content: center; | |
| 1168 | - | border-radius: var(--radius-sm); | |
| 1169 | - | font-size: 9px; | |
| 1170 | - | font-weight: 700; | |
| 1171 | - | font-family: var(--font-mono); | |
| 1172 | - | } | |
| 1173 | - | .file-status-added { | |
| 1174 | - | background: var(--color-success-bg); | |
| 1175 | - | color: var(--color-success); | |
| 1176 | - | } | |
| 1177 | - | .file-status-deleted { | |
| 1178 | - | background: var(--color-danger-bg); | |
| 1179 | - | color: var(--color-danger); | |
| 1180 | - | } | |
| 1181 | - | .file-status-modified { | |
| 1182 | - | background: var(--color-accent-bg); | |
| 1183 | - | color: var(--color-accent); | |
| 1184 | - | } | |
| 1185 | - | .file-status-renamed { | |
| 1186 | - | background: var(--color-merged-bg); | |
| 1187 | - | color: var(--color-merged); | |
| 1188 | - | } | |
| 1189 | - | .file-status-copied { | |
| 1190 | - | background: var(--color-merged-bg); | |
| 1191 | - | color: var(--color-merged); | |
| 1192 | - | } | |
| 1193 | - | .file-nav-name { | |
| 1194 | - | flex: 1; | |
| 1195 | - | overflow: hidden; | |
| 1196 | - | text-overflow: ellipsis; | |
| 1197 | - | white-space: nowrap; | |
| 1198 | - | font-family: var(--font-mono); | |
| 1199 | - | } | |
| 1200 | - | .file-nav-stat { | |
| 1201 | - | flex-shrink: 0; | |
| 1202 | - | display: flex; | |
| 1203 | - | gap: var(--space-1); | |
| 1204 | - | font-size: var(--text-xs); | |
| 1205 | - | font-family: var(--font-mono); | |
| 1206 | - | } | |
| 1207 | - | .nav-add { | |
| 1208 | - | color: var(--color-success); | |
| 1209 | - | } | |
| 1210 | - | .nav-del { | |
| 1211 | - | color: var(--color-danger); | |
| 1212 | - | } | |
| 1213 | - | .nav-binary { | |
| 1214 | - | color: var(--color-muted); | |
| 1215 | - | font-size: var(--text-xs); | |
| 1216 | - | } | |
| 1217 | - | .file-nav-dir > details > .file-nav-list { | |
| 1218 | - | padding-left: var(--space-4); | |
| 1219 | - | } | |
| 1220 | - | .file-nav-dir-toggle { | |
| 1221 | - | display: flex; | |
| 1222 | - | align-items: center; | |
| 1223 | - | gap: var(--space-1); | |
| 1224 | - | padding: var(--space-1) var(--space-3); | |
| 1225 | - | font-size: var(--text-xs); | |
| 1226 | - | font-family: var(--font-mono); | |
| 1227 | - | color: var(--color-text-muted); | |
| 1228 | - | cursor: pointer; | |
| 1229 | - | list-style: none; | |
| 1230 | - | user-select: none; | |
| 1231 | - | } | |
| 1232 | - | .file-nav-dir-toggle::-webkit-details-marker { | |
| 1233 | - | display: none; | |
| 1234 | - | } | |
| 1235 | - | .file-nav-dir > details:not([open]) .file-nav-toggle-icon { | |
| 1236 | - | transform: rotate(-90deg); | |
| 1237 | - | } | |
| 1238 | - | ||
| 1239 | - | /* --- Diff file cards --- */ | |
| 1240 | - | .diff-file { | |
| 1241 | - | border: 1px solid var(--color-border); | |
| 1242 | - | border-radius: var(--radius-lg); | |
| 1243 | - | margin-bottom: var(--space-4); | |
| 1244 | - | overflow: hidden; | |
| 1245 | - | } | |
| 1246 | - | .diff-file-header { | |
| 1247 | - | display: flex; | |
| 1248 | - | align-items: center; | |
| 1249 | - | justify-content: space-between; | |
| 1250 | - | gap: var(--space-3); | |
| 1251 | - | padding: var(--space-3) var(--space-4); | |
| 1252 | - | background: var(--color-bg-subtle); | |
| 1253 | - | cursor: pointer; | |
| 1254 | - | list-style: none; | |
| 1255 | - | user-select: none; | |
| 1256 | - | flex-wrap: wrap; | |
| 1257 | - | } | |
| 1258 | - | .diff-file-header::-webkit-details-marker { | |
| 1259 | - | display: none; | |
| 1260 | - | } | |
| 1261 | - | .diff-file-header-left { | |
| 1262 | - | display: flex; | |
| 1263 | - | align-items: center; | |
| 1264 | - | gap: var(--space-2); | |
| 1265 | - | min-width: 0; | |
| 1266 | - | flex: 1; | |
| 1267 | - | } | |
| 1268 | - | .diff-file-header-right { | |
| 1269 | - | display: flex; | |
| 1270 | - | align-items: center; | |
| 1271 | - | gap: var(--space-2); | |
| 1272 | - | flex-shrink: 0; | |
| 1273 | - | flex-wrap: wrap; | |
| 1274 | - | } | |
| 1275 | - | .diff-file-toggle-icon { | |
| 1276 | - | font-size: 0.7em; | |
| 1277 | - | flex-shrink: 0; | |
| 1278 | - | transition: transform 0.15s; | |
| 1279 | - | } | |
| 1280 | - | .diff-file:not([open]) .diff-file-toggle-icon { | |
| 1281 | - | transform: rotate(-90deg); | |
| 1282 | - | } | |
| 1283 | - | .diff-status-badge { | |
| 1284 | - | flex-shrink: 0; | |
| 1285 | - | width: 18px; | |
| 1286 | - | height: 18px; | |
| 1287 | - | display: inline-flex; | |
| 1288 | - | align-items: center; | |
| 1289 | - | justify-content: center; | |
| 1290 | - | border-radius: var(--radius-sm); | |
| 1291 | - | font-size: 10px; | |
| 1292 | - | font-weight: 700; | |
| 1293 | - | font-family: var(--font-mono); | |
| 1294 | - | } | |
| 1295 | - | .diff-status-added { | |
| 1296 | - | background: var(--color-success-bg); | |
| 1297 | - | color: var(--color-success); | |
| 1298 | - | } | |
| 1299 | - | .diff-status-deleted { | |
| 1300 | - | background: var(--color-danger-bg); | |
| 1301 | - | color: var(--color-danger); | |
| 1302 | - | } | |
| 1303 | - | .diff-status-modified { | |
| 1304 | - | background: var(--color-accent-bg); | |
| 1305 | - | color: var(--color-accent); | |
| 1306 | - | } | |
| 1307 | - | .diff-status-renamed { | |
| 1308 | - | background: var(--color-merged-bg); | |
| 1309 | - | color: var(--color-merged); | |
| 1310 | - | } | |
| 1311 | - | .diff-status-copied { | |
| 1312 | - | background: var(--color-merged-bg); | |
| 1313 | - | color: var(--color-merged); | |
| 1314 | - | } | |
| 1315 | - | .diff-file-path { | |
| 1316 | - | font-size: var(--text-sm); | |
| 1317 | - | font-weight: 500; | |
| 1318 | - | overflow: hidden; | |
| 1319 | - | text-overflow: ellipsis; | |
| 1320 | - | white-space: nowrap; | |
| 1321 | - | } | |
| 1322 | - | .diff-rename-arrow { | |
| 1323 | - | font-size: var(--text-xs); | |
| 1324 | - | color: var(--color-text-muted); | |
| 1325 | - | white-space: nowrap; | |
| 1326 | - | overflow: hidden; | |
| 1327 | - | text-overflow: ellipsis; | |
| 1328 | - | } | |
| 1329 | - | .diff-stat-add { | |
| 1330 | - | color: var(--color-success); | |
| 1331 | - | font-size: var(--text-sm); | |
| 1332 | - | font-family: var(--font-mono); | |
| 1333 | - | font-weight: 600; | |
| 1334 | - | } | |
| 1335 | - | .diff-stat-del { | |
| 1336 | - | color: var(--color-danger); | |
| 1337 | - | font-size: var(--text-sm); | |
| 1338 | - | font-family: var(--font-mono); | |
| 1339 | - | font-weight: 600; | |
| 1340 | - | } | |
| 1341 | - | .diff-stat-binary { | |
| 1342 | - | color: var(--color-muted); | |
| 1343 | - | font-size: var(--text-sm); | |
| 1344 | - | font-family: var(--font-mono); | |
| 1345 | - | } | |
| 1346 | - | .btn-xs { | |
| 1347 | - | padding: 1px var(--space-2); | |
| 1348 | - | font-size: var(--text-xs); | |
| 1349 | - | border: 1px solid var(--color-border); | |
| 1350 | - | border-radius: var(--radius-sm); | |
| 1351 | - | background: var(--color-bg); | |
| 1352 | - | color: var(--color-text); | |
| 1353 | - | text-decoration: none; | |
| 1354 | - | cursor: pointer; | |
| 1355 | - | white-space: nowrap; | |
| 1356 | - | font-family: var(--font-mono); | |
| 1357 | - | display: inline-flex; | |
| 1358 | - | align-items: center; | |
| 1359 | - | transition: background 0.1s; | |
| 1360 | - | } | |
| 1361 | - | .btn-xs:hover { | |
| 1362 | - | background: var(--color-bg-inset); | |
| 1363 | - | } | |
| 1364 | - | ||
| 1365 | - | /* --- Diff file body --- */ | |
| 1366 | - | .diff-binary-notice { | |
| 1367 | - | padding: var(--space-4) var(--space-6); | |
| 1368 | - | font-size: var(--text-sm); | |
| 1369 | - | color: var(--color-text-muted); | |
| 1370 | - | font-style: italic; | |
| 1371 | - | } | |
| 1372 | - | .diff-hunk { | |
| 1373 | - | border-top: 1px solid var(--color-border-muted); | |
| 1374 | - | overflow-x: auto; | |
| 1375 | - | } | |
| 1376 | - | .diff-hunk:first-child { | |
| 1377 | - | border-top: none; | |
| 1378 | - | } | |
| 1379 | - | .diff-hunk-header { | |
| 1380 | - | padding: var(--space-1) var(--space-4); | |
| 1381 | - | font-family: var(--font-mono); | |
| 1382 | - | font-size: var(--text-xs); | |
| 1383 | - | font-weight: normal; | |
| 1384 | - | text-align: left; | |
| 1385 | - | background: color-mix( | |
| 1386 | - | in srgb, | |
| 1387 | - | var(--color-merged-bg) 50%, | |
| 1388 | - | var(--color-bg) | |
| 1389 | - | ); | |
| 1390 | - | color: var(--color-merged); | |
| 1391 | - | border-bottom: 1px solid var(--color-border-muted); | |
| 1392 | - | white-space: pre; | |
| 1393 | - | } | |
| 1394 | - | ||
| 1395 | - | /* --- Diff table --- */ | |
| 1396 | - | .diff-table { | |
| 1397 | - | min-width: 100%; | |
| 1398 | - | border-collapse: collapse; | |
| 1399 | - | font-family: var(--font-mono); | |
| 1400 | - | font-size: var(--text-xs); | |
| 1401 | - | line-height: 1.5; | |
| 1402 | - | } | |
| 1403 | - | .diff-ln { | |
| 1404 | - | width: 44px; | |
| 1405 | - | min-width: 44px; | |
| 1406 | - | text-align: right; | |
| 1407 | - | padding: 0 var(--space-2); | |
| 1408 | - | color: var(--color-text-muted); | |
| 1409 | - | border-right: 1px solid var(--color-border-muted); | |
| 1410 | - | user-select: none; | |
| 1411 | - | vertical-align: top; | |
| 1412 | - | white-space: nowrap; | |
| 1413 | - | } | |
| 1414 | - | .diff-sign { | |
| 1415 | - | width: 18px; | |
| 1416 | - | min-width: 18px; | |
| 1417 | - | text-align: center; | |
| 1418 | - | padding: 0 var(--space-1); | |
| 1419 | - | user-select: none; | |
| 1420 | - | vertical-align: top; | |
| 1421 | - | } | |
| 1422 | - | .diff-code { | |
| 1423 | - | padding: 0 var(--space-3); | |
| 1424 | - | white-space: pre; | |
| 1425 | - | overflow: visible; | |
| 1426 | - | vertical-align: top; | |
| 1427 | - | word-break: normal; | |
| 1428 | - | } | |
| 1429 | - | /* Row type backgrounds */ | |
| 1430 | - | .diff-row-add { | |
| 1431 | - | background: color-mix( | |
| 1432 | - | in srgb, | |
| 1433 | - | var(--color-success-bg) 70%, | |
| 1434 | - | transparent | |
| 1435 | - | ); | |
| 1436 | - | } | |
| 1437 | - | .diff-row-del { | |
| 1438 | - | background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent); | |
| 1439 | - | } | |
| 1440 | - | .diff-row-add .diff-ln, | |
| 1441 | - | .diff-row-add .diff-sign { | |
| 1442 | - | background: color-mix( | |
| 1443 | - | in srgb, | |
| 1444 | - | var(--color-success-bg) 90%, | |
| 1445 | - | transparent | |
| 1446 | - | ); | |
| 1447 | - | } | |
| 1448 | - | .diff-row-del .diff-ln, | |
| 1449 | - | .diff-row-del .diff-sign { | |
| 1450 | - | background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent); | |
| 1451 | - | } | |
| 1452 | - | .diff-row-add .diff-sign { | |
| 1453 | - | color: var(--color-success); | |
| 1454 | - | font-weight: 700; | |
| 1455 | - | } | |
| 1456 | - | .diff-row-del .diff-sign { | |
| 1457 | - | color: var(--color-danger); | |
| 1458 | - | font-weight: 700; | |
| 1459 | - | } | |
| 1460 | - | ||
| 1461 | - | .diff-ctrl { | |
| 1462 | - | color: #f0f0f0; | |
| 1463 | - | background: #c0392b; | |
| 1464 | - | border-radius: 2px; | |
| 1465 | - | font-weight: bold; | |
| 1466 | - | padding: 0 1px; | |
| 1467 | - | } | |
| 1468 | - | ||
| 1469 | - | /* Shiki dark-mode support in diff code cells */ | |
| 1470 | - | @media (prefers-color-scheme: dark) { | |
| 1471 | - | .diff-code span[style] { | |
| 1472 | - | /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */ | |
| 1473 | - | color: var(--shiki-dark) !important; | |
| 1474 | - | } | |
| 1475 | - | } | |
| 1476 | - | ||
| 1477 | - | /* Shiki dark-mode support in file blob view */ | |
| 1478 | - | @media (prefers-color-scheme: dark) { | |
| 1479 | - | .blob-table { | |
| 1480 | - | background-color: var(--shiki-dark-bg); | |
| 1481 | - | color: var(--shiki-dark); | |
| 1482 | - | } | |
| 1483 | - | .blob-table span[style] { | |
| 1484 | - | /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */ | |
| 1485 | - | color: var(--shiki-dark) !important; | |
| 1486 | - | } | |
| 1487 | - | .blob-table tr:target { | |
| 1488 | - | background: color-mix(in srgb, #1c3a52 60%, transparent); | |
| 1489 | - | } | |
| 1490 | - | .blob-table tr:target .blob-ln { | |
| 1491 | - | background: color-mix(in srgb, #1c3a52 80%, transparent); | |
| 1492 | - | } | |
| 1493 | - | } | |
| 1494 | - | :root[data-theme="dark"] .blob-table { | |
| 1495 | - | background-color: var(--shiki-dark-bg); | |
| 1496 | - | color: var(--shiki-dark); | |
| 1497 | - | } | |
| 1498 | - | :root[data-theme="dark"] .blob-table span[style] { | |
| 1499 | - | /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */ | |
| 1500 | - | color: var(--shiki-dark) !important; | |
| 1501 | - | } | |
| 1502 | - | ||
| 1503 | - | /* --- Issue / Patch list --- */ | |
| 1504 | - | .list-header { | |
| 1505 | - | display: flex; | |
| 1506 | - | align-items: center; | |
| 1507 | - | justify-content: space-between; | |
| 1508 | - | padding: var(--space-3) var(--space-4); | |
| 1509 | - | background: var(--color-bg-subtle); | |
| 1510 | - | border: 1px solid var(--color-border); | |
| 1511 | - | border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| 1512 | - | gap: var(--space-4); | |
| 1513 | - | } | |
| 1514 | - | .list-header-tabs { | |
| 1515 | - | display: flex; | |
| 1516 | - | gap: var(--space-2); | |
| 1517 | - | } | |
| 1518 | - | .list-tab { | |
| 1519 | - | padding: var(--space-1) var(--space-3); | |
| 1520 | - | border-radius: var(--radius-md); | |
| 1521 | - | font-size: var(--text-sm); | |
| 1522 | - | text-decoration: none; | |
| 1523 | - | color: var(--color-text-muted); | |
| 1524 | - | } | |
| 1525 | - | .list-tab:hover { | |
| 1526 | - | background: var(--color-bg-inset); | |
| 1527 | - | color: var(--color-text); | |
| 1528 | - | } | |
| 1529 | - | .list-tab.active { | |
| 1530 | - | background: var(--color-bg-inset); | |
| 1531 | - | color: var(--color-text); | |
| 1532 | - | font-weight: 600; | |
| 1533 | - | } | |
| 1534 | - | .issue-list { | |
| 1535 | - | border: 1px solid var(--color-border); | |
| 1536 | - | border-top: none; | |
| 1537 | - | border-radius: 0 0 var(--radius-lg) var(--radius-lg); | |
| 1538 | - | } | |
| 1539 | - | .issue-item { | |
| 1540 | - | padding: var(--space-4) var(--space-4); | |
| 1541 | - | border-bottom: 1px solid var(--color-border-muted); | |
| 1542 | - | } | |
| 1543 | - | .issue-item:last-child { | |
| 1544 | - | border-bottom: none; | |
| 1545 | - | } | |
| 1546 | - | .issue-main { | |
| 1547 | - | display: flex; | |
| 1548 | - | align-items: center; | |
| 1549 | - | gap: var(--space-2); | |
| 1550 | - | margin-bottom: var(--space-1); | |
| 1551 | - | flex-wrap: wrap; | |
| 1552 | - | } | |
| 1553 | - | .issue-title { | |
| 1554 | - | font-weight: 500; | |
| 1555 | - | text-decoration: none; | |
| 1556 | - | color: var(--color-text); | |
| 1557 | - | flex: 1; | |
| 1558 | - | } | |
| 1559 | - | .issue-title:hover { | |
| 1560 | - | color: var(--color-link); | |
| 1561 | - | } | |
| 1562 | - | .issue-meta { | |
| 1563 | - | font-size: var(--text-xs); | |
| 1564 | - | color: var(--color-text-muted); | |
| 1565 | - | display: flex; | |
| 1566 | - | gap: var(--space-3); | |
| 1567 | - | } | |
| 1568 | - | .issue-status-dot, | |
| 1569 | - | .patch-status-dot { | |
| 1570 | - | width: 8px; | |
| 1571 | - | height: 8px; | |
| 1572 | - | border-radius: 50%; | |
| 1573 | - | flex-shrink: 0; | |
| 1574 | - | } | |
| 1575 | - | .issue-status-dot.open, | |
| 1576 | - | .patch-status-dot.open { | |
| 1577 | - | background: var(--color-success); | |
| 1578 | - | } | |
| 1579 | - | .issue-status-dot.closed, | |
| 1580 | - | .patch-status-dot.closed { | |
| 1581 | - | background: var(--color-danger); | |
| 1582 | - | } | |
| 1583 | - | .issue-status-dot.completed { | |
| 1584 | - | background: var(--color-merged); | |
| 1585 | - | } | |
| 1586 | - | .patch-status-dot.merged { | |
| 1587 | - | background: var(--color-merged); | |
| 1588 | - | } | |
| 1589 | - | ||
| 1590 | - | /* --- Issue detail / Timeline --- */ | |
| 1591 | - | .issue-detail-header { | |
| 1592 | - | display: flex; | |
| 1593 | - | align-items: center; | |
| 1594 | - | flex-wrap: wrap; | |
| 1595 | - | gap: var(--space-3); | |
| 1596 | - | margin-bottom: var(--space-4); | |
| 1597 | - | } | |
| 1598 | - | .issue-number { | |
| 1599 | - | font-size: var(--text-2xl); | |
| 1600 | - | font-weight: 600; | |
| 1601 | - | color: var(--color-text-muted); | |
| 1602 | - | white-space: nowrap; | |
| 1603 | - | } | |
| 1604 | - | .issue-detail-title { | |
| 1605 | - | font-size: var(--text-2xl); | |
| 1606 | - | font-weight: 600; | |
| 1607 | - | overflow-wrap: anywhere; | |
| 1608 | - | } | |
| 1609 | - | .issue-detail-meta-actions { | |
| 1610 | - | margin-left: auto; | |
| 1611 | - | display: flex; | |
| 1612 | - | gap: var(--space-2); | |
| 1613 | - | flex-shrink: 0; | |
| 1614 | - | } | |
| 1615 | - | .patch-author-meta { | |
| 1616 | - | display: flex; | |
| 1617 | - | align-items: center; | |
| 1618 | - | gap: var(--space-2); | |
| 1619 | - | font-size: var(--text-xs); | |
| 1620 | - | margin-top: calc(-1 * var(--space-4)); | |
| 1621 | - | margin-bottom: var(--space-6); | |
| 1622 | - | } | |
| 1623 | - | .patch-author-label { | |
| 1624 | - | color: var(--color-text-muted); | |
| 1625 | - | } | |
| 1626 | - | .patch-author-identity { | |
| 1627 | - | font-family: monospace; | |
| 1628 | - | } | |
| 1629 | - | .timeline-item { | |
| 1630 | - | border: 1px solid var(--color-border); | |
| 1631 | - | border-radius: var(--radius-lg); | |
| 1632 | - | margin-bottom: var(--space-4); | |
| 1633 | - | overflow: hidden; | |
| 1634 | - | } | |
| 1635 | - | .timeline-author { | |
| 1636 | - | background: var(--color-bg-subtle); | |
| 1637 | - | padding: var(--space-3) var(--space-4); | |
| 1638 | - | font-size: var(--text-sm); | |
| 1639 | - | display: flex; | |
| 1640 | - | align-items: center; | |
| 1641 | - | justify-content: space-between; | |
| 1642 | - | gap: var(--space-3); | |
| 1643 | - | border-bottom: 1px solid var(--color-border); | |
| 1644 | - | overflow-wrap: anywhere; | |
| 1645 | - | } | |
| 1646 | - | .timeline-author time { | |
| 1647 | - | color: var(--color-text-muted); | |
| 1648 | - | font-size: var(--text-xs); | |
| 1649 | - | width: max-content; | |
| 1650 | - | } | |
| 1651 | - | .timeline-author-right { | |
| 1652 | - | margin-left: auto; | |
| 1653 | - | display: flex; | |
| 1654 | - | align-items: center; | |
| 1655 | - | gap: var(--space-2); | |
| 1656 | - | } | |
| 1657 | - | .edited-indicator { | |
| 1658 | - | color: var(--color-text-muted); | |
| 1659 | - | font-size: var(--text-xs); | |
| 1660 | - | cursor: default; | |
| 1661 | - | } | |
| 1662 | - | ||
| 1663 | - | /* --- Avatars --- */ | |
| 1664 | - | .avatar { | |
| 1665 | - | display: inline-flex; | |
| 1666 | - | border-radius: 4px; | |
| 1667 | - | overflow: hidden; | |
| 1668 | - | flex-shrink: 0; | |
| 1669 | - | vertical-align: middle; | |
| 1670 | - | } | |
| 1671 | - | .avatar-img { | |
| 1672 | - | width: 100%; | |
| 1673 | - | height: 100%; | |
| 1674 | - | object-fit: cover; | |
| 1675 | - | display: block; | |
| 1676 | - | color: transparent; | |
| 1677 | - | } | |
| 1678 | - | .avatar-placeholder { | |
| 1679 | - | display: inline-flex; | |
| 1680 | - | align-items: center; | |
| 1681 | - | justify-content: center; | |
| 1682 | - | border-radius: 4px; | |
| 1683 | - | background: var(--color-bg-inset); | |
| 1684 | - | color: var(--color-text-muted); | |
| 1685 | - | font-weight: 600; | |
| 1686 | - | flex-shrink: 0; | |
| 1687 | - | vertical-align: middle; | |
| 1688 | - | } | |
| 1689 | - | .avatar-settings { | |
| 1690 | - | display: flex; | |
| 1691 | - | align-items: flex-start; | |
| 1692 | - | gap: var(--space-5); | |
| 1693 | - | } | |
| 1694 | - | .avatar-actions { | |
| 1695 | - | display: flex; | |
| 1696 | - | flex-direction: column; | |
| 1697 | - | gap: var(--space-3); | |
| 1698 | - | } | |
| 1699 | - | .nav-user { | |
| 1700 | - | display: inline-flex; | |
| 1701 | - | align-items: center; | |
| 1702 | - | gap: var(--space-2); | |
| 1703 | - | } | |
| 1704 | - | .timeline-body { | |
| 1705 | - | padding: var(--space-4); | |
| 1706 | - | } | |
| 1707 | - | .timeline-item-new { | |
| 1708 | - | padding: var(--space-4); | |
| 1709 | - | } | |
| 1710 | - | .timeline-item-new .section-title { | |
| 1711 | - | font-size: var(--text-base); | |
| 1712 | - | border: none; | |
| 1713 | - | padding-bottom: 0; | |
| 1714 | - | } | |
| 1715 | - | /* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */ | |
| 1716 | - | .timeline-author-right .inline-edit-details { | |
| 1717 | - | border: none; | |
| 1718 | - | } | |
| 1719 | - | .timeline-author-right .inline-edit-details > summary { | |
| 1720 | - | list-style: none; | |
| 1721 | - | } | |
| 1722 | - | .timeline-author-right | |
| 1723 | - | .inline-edit-details | |
| 1724 | - | > summary::-webkit-details-marker { | |
| 1725 | - | display: none; | |
| 1726 | - | } | |
| 1727 | - | .inline-edit-details .when-open { | |
| 1728 | - | display: none; | |
| 1729 | - | } | |
| 1730 | - | .inline-edit-details[open] .when-closed { | |
| 1731 | - | display: none; | |
| 1732 | - | } | |
| 1733 | - | .inline-edit-details[open] .when-open { | |
| 1734 | - | display: inline; | |
| 1735 | - | } | |
| 1736 | - | .inline-edit-form-area { | |
| 1737 | - | display: none; | |
| 1738 | - | border-top: 1px solid var(--color-border-muted); | |
| 1739 | - | } | |
| 1740 | - | .timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area { | |
| 1741 | - | display: block; | |
| 1742 | - | } | |
| 1743 | - | .timeline-item:has(.inline-edit-details[open]) .timeline-body { | |
| 1744 | - | display: none; | |
| 1745 | - | } | |
| 1746 | - | ||
| 1747 | - | /* Title edit in issue/patch header */ | |
| 1748 | - | .issue-detail-header { | |
| 1749 | - | align-items: flex-start; | |
| 1750 | - | } | |
| 1751 | - | .title-with-edit { | |
| 1752 | - | flex: 1; | |
| 1753 | - | min-width: 16rem; | |
| 1754 | - | display: flex; | |
| 1755 | - | flex-direction: column; | |
| 1756 | - | gap: var(--space-1); | |
| 1757 | - | } | |
| 1758 | - | .title-edit-details { | |
| 1759 | - | border: none; | |
| 1760 | - | align-self: flex-start; | |
| 1761 | - | } | |
| 1762 | - | .title-with-edit:has(.title-edit-details[open]) .issue-detail-title { | |
| 1763 | - | display: none; | |
| 1764 | - | } | |
| 1765 | - | .title-with-edit:has(.title-edit-details[open]) > .title-edit-details { | |
| 1766 | - | display: none; | |
| 1767 | - | } | |
| 1768 | - | .title-edit-details > summary { | |
| 1769 | - | list-style: none; | |
| 1770 | - | } | |
| 1771 | - | .title-edit-details > summary::-webkit-details-marker { | |
| 1772 | - | display: none; | |
| 1773 | - | } | |
| 1774 | - | .title-edit-form-area { | |
| 1775 | - | display: none; | |
| 1776 | - | } | |
| 1777 | - | .title-with-edit:has(.title-edit-details[open]) .title-edit-form-area { | |
| 1778 | - | display: flex; | |
| 1779 | - | flex: 1; | |
| 1780 | - | min-width: 0; | |
| 1781 | - | } | |
| 1782 | - | .title-edit-form { | |
| 1783 | - | display: flex; | |
| 1784 | - | flex-direction: column; | |
| 1785 | - | gap: var(--space-2); | |
| 1786 | - | flex: 1; | |
| 1787 | - | min-width: 0; | |
| 1788 | - | } | |
| 1789 | - | .title-edit-form .form-input { | |
| 1790 | - | min-width: 0; | |
| 1791 | - | width: 100%; | |
| 1792 | - | padding: var(--space-2) var(--space-3); | |
| 1793 | - | border: 1px solid var(--color-border); | |
| 1794 | - | border-radius: var(--radius-md); | |
| 1795 | - | background: var(--color-bg); | |
| 1796 | - | color: var(--color-text); | |
| 1797 | - | font-size: var(--text-sm); | |
| 1798 | - | } | |
| 1799 | - | .title-edit-form .form-input:focus { | |
| 1800 | - | outline: 2px solid var(--color-accent); | |
| 1801 | - | outline-offset: -1px; | |
| 1802 | - | border-color: var(--color-accent); | |
| 1803 | - | } | |
| 1804 | - | .title-edit-actions { | |
| 1805 | - | display: flex; | |
| 1806 | - | gap: var(--space-2); | |
| 1807 | - | } | |
| 1808 | - | ||
| 1809 | - | .inline-edit-form { | |
| 1810 | - | padding: var(--space-4); | |
| 1811 | - | display: flex; | |
| 1812 | - | flex-direction: column; | |
| 1813 | - | gap: var(--space-3); | |
| 1814 | - | } | |
| 1815 | - | ||
| 1816 | - | /* --- Reactions --- */ | |
| 1817 | - | .reaction-bar { | |
| 1818 | - | display: flex; | |
| 1819 | - | flex-wrap: wrap; | |
| 1820 | - | gap: var(--space-2); | |
| 1821 | - | padding: var(--space-2) var(--space-4); | |
| 1822 | - | border-top: 1px solid var(--color-border-muted); | |
| 1823 | - | } | |
| 1824 | - | .reaction-form { | |
| 1825 | - | display: inline; | |
| 1826 | - | } | |
| 1827 | - | .reaction-btn { | |
| 1828 | - | display: inline-flex; | |
| 1829 | - | align-items: center; | |
| 1830 | - | gap: var(--space-1); | |
| 1831 | - | padding: var(--space-1) var(--space-3); | |
| 1832 | - | border: 1px solid var(--color-border); | |
| 1833 | - | border-radius: var(--radius-full); | |
| 1834 | - | background: var(--color-bg-subtle); | |
| 1835 | - | color: var(--color-text); | |
| 1836 | - | font-size: var(--text-sm); | |
| 1837 | - | cursor: pointer; | |
| 1838 | - | transition: background 0.1s; | |
| 1839 | - | } | |
| 1840 | - | .reaction-btn:hover { | |
| 1841 | - | background: var(--color-bg-inset); | |
| 1842 | - | } | |
| 1843 | - | .reaction-btn.reacted { | |
| 1844 | - | background: var(--color-accent-bg); | |
| 1845 | - | border-color: var(--color-accent); | |
| 1846 | - | } | |
| 1847 | - | .reaction-picker { | |
| 1848 | - | position: relative; | |
| 1849 | - | } | |
| 1850 | - | .reaction-add-btn { | |
| 1851 | - | display: inline-flex; | |
| 1852 | - | align-items: center; | |
| 1853 | - | justify-content: center; | |
| 1854 | - | width: 28px; | |
| 1855 | - | height: 28px; | |
| 1856 | - | border: 1px dashed var(--color-border); | |
| 1857 | - | border-radius: var(--radius-full); | |
| 1858 | - | background: transparent; | |
| 1859 | - | cursor: pointer; | |
| 1860 | - | font-size: var(--text-base); | |
| 1861 | - | color: var(--color-text-muted); | |
| 1862 | - | list-style: none; | |
| 1863 | - | } | |
| 1864 | - | .reaction-add-btn::-webkit-details-marker { | |
| 1865 | - | display: none; | |
| 1866 | - | } | |
| 1867 | - | .reaction-picker-dropdown { | |
| 1868 | - | position: absolute; | |
| 1869 | - | bottom: calc(100% + var(--space-2)); | |
| 1870 | - | left: 0; | |
| 1871 | - | background: var(--color-bg); | |
| 1872 | - | border: 1px solid var(--color-border); | |
| 1873 | - | border-radius: var(--radius-lg); | |
| 1874 | - | padding: var(--space-2); | |
| 1875 | - | display: flex; | |
| 1876 | - | gap: var(--space-1); | |
| 1877 | - | z-index: 10; | |
| 1878 | - | } | |
| 1879 | - | .reaction-picker-btn { | |
| 1880 | - | background: none; | |
| 1881 | - | border: none; | |
| 1882 | - | font-size: 1.25rem; | |
| 1883 | - | cursor: pointer; | |
| 1884 | - | padding: var(--space-1); | |
| 1885 | - | border-radius: var(--radius-sm); | |
| 1886 | - | } | |
| 1887 | - | .reaction-picker-btn:hover { | |
| 1888 | - | background: var(--color-bg-inset); | |
| 1889 | - | } | |
| 1890 | - | ||
| 1891 | - | /* --- Patch subview tabs --- */ | |
| 1892 | - | .patch-tab-nav { | |
| 1893 | - | display: flex; | |
| 1894 | - | border-bottom: 1px solid var(--color-border); | |
| 1895 | - | margin-bottom: var(--space-6); | |
| 1896 | - | } | |
| 1897 | - | .tab-count { | |
| 1898 | - | display: inline-flex; | |
| 1899 | - | align-items: center; | |
| 1900 | - | justify-content: center; | |
| 1901 | - | min-width: 1.4em; | |
| 1902 | - | padding: 0 var(--space-1); | |
| 1903 | - | margin-left: var(--space-1); | |
| 1904 | - | font-size: var(--text-xs); | |
| 1905 | - | font-weight: 500; | |
| 1906 | - | background: var(--color-bg-inset); | |
| 1907 | - | border-radius: 999px; | |
| 1908 | - | color: var(--color-text-muted); | |
| 1909 | - | } | |
| 1910 | - | ||
| 1911 | - | /* --- Patch apply status --- */ | |
| 1912 | - | .apply-status { | |
| 1913 | - | margin: var(--space-4) 0; | |
| 1914 | - | } | |
| 1915 | - | .apply-result { | |
| 1916 | - | display: flex; | |
| 1917 | - | align-items: flex-start; | |
| 1918 | - | gap: var(--space-2); | |
| 1919 | - | padding: var(--space-3) var(--space-4); | |
| 1920 | - | border-radius: var(--radius-md); | |
| 1921 | - | font-size: var(--text-sm); | |
| 1922 | - | flex-direction: column; | |
| 1923 | - | } | |
| 1924 | - | .apply-result > div { | |
| 1925 | - | display: flex; | |
| 1926 | - | gap: var(--space-2); | |
| 1927 | - | align-items: center; | |
| 1928 | - | } | |
| 1929 | - | .apply-clean { | |
| 1930 | - | background: var(--color-success-bg); | |
| 1931 | - | color: var(--color-success); | |
| 1932 | - | border: 1px solid var(--color-success); | |
| 1933 | - | } | |
| 1934 | - | .apply-conflict { | |
| 1935 | - | background: var(--color-danger-bg); | |
| 1936 | - | color: var(--color-danger); | |
| 1937 | - | border: 1px solid var(--color-danger); | |
| 1938 | - | } | |
| 1939 | - | .apply-checking, | |
| 1940 | - | .apply-unknown { | |
| 1941 | - | color: var(--color-text-muted); | |
| 1942 | - | font-style: italic; | |
| 1943 | - | font-size: var(--text-sm); | |
| 1944 | - | } | |
| 1945 | - | .apply-output { | |
| 1946 | - | font-family: var(--font-mono); | |
| 1947 | - | font-size: var(--text-xs); | |
| 1948 | - | white-space: pre-wrap; | |
| 1949 | - | margin-top: var(--space-2); | |
| 1950 | - | padding: var(--space-2); | |
| 1951 | - | background: rgba(0, 0, 0, 0.05); | |
| 1952 | - | border-radius: var(--radius-sm); | |
| 1953 | - | width: 100%; | |
| 1954 | - | } | |
| 1955 | - | .patch-actions { | |
| 1956 | - | display: flex; | |
| 1957 | - | gap: var(--space-3); | |
| 1958 | - | margin: var(--space-4) 0; | |
| 1959 | - | flex-wrap: wrap; | |
| 1960 | - | } | |
| 1961 | - | .patch-card-title-row { | |
| 1962 | - | display: flex; | |
| 1963 | - | align-items: flex-start; | |
| 1964 | - | gap: var(--space-3); | |
| 1965 | - | justify-content: space-between; | |
| 1966 | - | margin-bottom: var(--space-3); | |
| 1967 | - | } | |
| 1968 | - | .patch-card-title-row .commit-card-subject { | |
| 1969 | - | margin-bottom: 0; | |
| 1970 | - | } | |
| 1971 | - | .patch-card-description { | |
| 1972 | - | margin-top: var(--space-4); | |
| 1973 | - | padding-top: var(--space-4); | |
| 1974 | - | border-top: 1px solid var(--color-border-muted); | |
| 1975 | - | } | |
| 1976 | - | ||
| 1977 | - | /* --- Repo home layout --- */ | |
| 1978 | - | .repo-home-layout { | |
| 1979 | - | display: grid; | |
| 1980 | - | gap: var(--space-8); | |
| 1981 | - | } | |
| 1982 | - | @media (min-width: 768px) { | |
| 1983 | - | .repo-home-layout { | |
| 1984 | - | grid-template-columns: 1fr 1fr; | |
| 1985 | - | } | |
| 1986 | - | } | |
| 1987 | - | .readme-section { | |
| 1988 | - | border: 1px solid var(--color-border); | |
| 1989 | - | border-radius: var(--radius-lg); | |
| 1990 | - | overflow: hidden; | |
| 1991 | - | } | |
| 1992 | - | .readme-header { | |
| 1993 | - | background: var(--color-bg-subtle); | |
| 1994 | - | padding: var(--space-3) var(--space-4); | |
| 1995 | - | font-size: var(--text-sm); | |
| 1996 | - | font-weight: 500; | |
| 1997 | - | border-bottom: 1px solid var(--color-border); | |
| 1998 | - | display: flex; | |
| 1999 | - | align-items: center; | |
| 2000 | - | justify-content: space-between; | |
| 2001 | - | } | |
| 2002 | - | /* --- Code setup block --- */ | |
| 2003 | - | .code-setup { | |
| 2004 | - | background: var(--color-bg-subtle); | |
| 2005 | - | border: 1px solid var(--color-border); | |
| 2006 | - | border-radius: var(--radius-lg); | |
| 2007 | - | padding: var(--space-4); | |
| 2008 | - | font-size: var(--text-sm); | |
| 2009 | - | margin-top: var(--space-4); | |
| 2010 | - | overflow: auto; | |
| 2011 | - | text-align: left; | |
| 2012 | - | } | |
| 2013 | - | .code-plain { | |
| 2014 | - | background: var(--color-bg-subtle); | |
| 2015 | - | padding: var(--space-4); | |
| 2016 | - | overflow: auto; | |
| 2017 | - | font-size: var(--text-sm); | |
| 2018 | - | line-height: 1.6; | |
| 2019 | - | } | |
| 2020 | - | ||
| 2021 | - | /* --- Markdown body --- */ | |
| 2022 | - | .markdown-body { | |
| 2023 | - | font-size: var(--text-sm); | |
| 2024 | - | line-height: 1.6; | |
| 2025 | - | color: var(--color-text); | |
| 2026 | - | } | |
| 2027 | - | .markdown-body h1, | |
| 2028 | - | .markdown-body h2, | |
| 2029 | - | .markdown-body h3, | |
| 2030 | - | .markdown-body h4, | |
| 2031 | - | .markdown-body h5, | |
| 2032 | - | .markdown-body h6 { | |
| 2033 | - | margin-top: var(--space-6); | |
| 2034 | - | margin-bottom: var(--space-3); | |
| 2035 | - | } | |
| 2036 | - | .markdown-body p { | |
| 2037 | - | margin-bottom: var(--space-4); | |
| 2038 | - | } | |
| 2039 | - | .markdown-body a { | |
| 2040 | - | color: var(--color-link); | |
| 2041 | - | } | |
| 2042 | - | .markdown-body code { | |
| 2043 | - | background: var(--color-bg-inset); | |
| 2044 | - | padding: 0.1em 0.3em; | |
| 2045 | - | border-radius: var(--radius-sm); | |
| 2046 | - | font-size: 0.9em; | |
| 2047 | - | } | |
| 2048 | - | .markdown-body pre { | |
| 2049 | - | background: var(--color-bg-subtle); | |
| 2050 | - | border: 1px solid var(--color-border); | |
| 2051 | - | border-radius: var(--radius-md); | |
| 2052 | - | padding: var(--space-4); | |
| 2053 | - | overflow: auto; | |
| 2054 | - | margin-bottom: var(--space-4); | |
| 2055 | - | } | |
| 2056 | - | .markdown-body pre code { | |
| 2057 | - | background: none; | |
| 2058 | - | padding: 0; | |
| 2059 | - | } | |
| 2060 | - | .markdown-body ul, | |
| 2061 | - | .markdown-body ol { | |
| 2062 | - | padding-left: var(--space-6); | |
| 2063 | - | margin-bottom: var(--space-4); | |
| 2064 | - | } | |
| 2065 | - | .markdown-body ul { | |
| 2066 | - | list-style: disc; | |
| 2067 | - | } | |
| 2068 | - | .markdown-body ol { | |
| 2069 | - | list-style: decimal; | |
| 2070 | - | } | |
| 2071 | - | .markdown-body li { | |
| 2072 | - | margin-bottom: var(--space-1); | |
| 2073 | - | } | |
| 2074 | - | .markdown-body blockquote { | |
| 2075 | - | border-left: 3px solid var(--color-border); | |
| 2076 | - | padding-left: var(--space-4); | |
| 2077 | - | color: var(--color-text-muted); | |
| 2078 | - | margin: var(--space-4) 0; | |
| 2079 | - | } | |
| 2080 | - | .markdown-body table { | |
| 2081 | - | width: 100%; | |
| 2082 | - | border-collapse: collapse; | |
| 2083 | - | margin-bottom: var(--space-4); | |
| 2084 | - | } | |
| 2085 | - | .markdown-body th, | |
| 2086 | - | .markdown-body td { | |
| 2087 | - | padding: var(--space-2) var(--space-3); | |
| 2088 | - | border: 1px solid var(--color-border); | |
| 2089 | - | } | |
| 2090 | - | .markdown-body th { | |
| 2091 | - | background: var(--color-bg-subtle); | |
| 2092 | - | font-weight: 600; | |
| 2093 | - | } | |
| 2094 | - | .markdown-body hr { | |
| 2095 | - | border: none; | |
| 2096 | - | border-top: 1px solid var(--color-border); | |
| 2097 | - | margin: var(--space-6) 0; | |
| 2098 | - | } | |
| 2099 | - | .markdown-body img { | |
| 2100 | - | max-width: 100%; | |
| 2101 | - | border-radius: var(--radius-md); | |
| 2102 | - | } | |
| 2103 | - | .markdown-body details { | |
| 2104 | - | margin-bottom: var(--space-4); | |
| 2105 | - | } | |
| 2106 | - | .markdown-body summary { | |
| 2107 | - | cursor: pointer; | |
| 2108 | - | font-weight: 500; | |
| 2109 | - | } | |
| 2110 | - | .readme-section .markdown-body, | |
| 2111 | - | .file-blob-body .markdown-body { | |
| 2112 | - | padding: var(--space-4); | |
| 2113 | - | } | |
| 2114 | - | ||
| 2115 | - | /* --- Search row (search form + sort select) --- */ | |
| 2116 | - | .search-row { | |
| 2117 | - | display: flex; | |
| 2118 | - | align-items: center; | |
| 2119 | - | gap: var(--space-3); | |
| 2120 | - | margin-bottom: var(--space-5); | |
| 2121 | - | } | |
| 2122 | - | .search-form { | |
| 2123 | - | flex: 1; | |
| 2124 | - | } | |
| 2125 | - | .repo-sort-select { | |
| 2126 | - | padding: var(--space-1) var(--space-2); | |
| 2127 | - | padding-right: var(--space-5); | |
| 2128 | - | border: 1px solid var(--color-border); | |
| 2129 | - | border-radius: var(--radius-md); | |
| 2130 | - | background: var(--color-bg-subtle); | |
| 2131 | - | font-size: var(--text-sm); | |
| 2132 | - | color: var(--color-text); | |
| 2133 | - | cursor: pointer; | |
| 2134 | - | appearance: auto; | |
| 2135 | - | } | |
| 2136 | - | .repo-sort-select:hover { | |
| 2137 | - | border-color: var(--color-text-muted); | |
| 2138 | - | } | |
| 2139 | - | .repo-sort-select:focus { | |
| 2140 | - | outline: 2px solid var(--color-accent); | |
| 2141 | - | outline-offset: -1px; | |
| 2142 | - | } | |
| 2143 | - | .search-input-wrap { | |
| 2144 | - | display: flex; | |
| 2145 | - | gap: var(--space-2); | |
| 2146 | - | } | |
| 2147 | - | .search-input { | |
| 2148 | - | flex: 1; | |
| 2149 | - | padding: var(--space-2) var(--space-3); | |
| 2150 | - | border: 1px solid var(--color-border); | |
| 2151 | - | border-radius: var(--radius-md); | |
| 2152 | - | background: var(--color-bg); | |
| 2153 | - | color: var(--color-text); | |
| 2154 | - | font-size: var(--text-sm); | |
| 2155 | - | } | |
| 2156 | - | .search-input:focus { | |
| 2157 | - | outline: 2px solid var(--color-accent); | |
| 2158 | - | outline-offset: -1px; | |
| 2159 | - | border-color: var(--color-accent); | |
| 2160 | - | } | |
| 2161 | - | ||
| 2162 | - | /* --- Issue close bar --- */ | |
| 2163 | - | .issue-close-bar { | |
| 2164 | - | display: flex; | |
| 2165 | - | justify-content: flex-end; | |
| 2166 | - | padding: var(--space-3) 0; | |
| 2167 | - | border-top: 1px solid var(--color-border-muted); | |
| 2168 | - | margin-top: var(--space-2); | |
| 2169 | - | } | |
| 2170 | - | ||
| 2171 | - | /* --- Auth divider --- */ | |
| 2172 | - | .auth-divider { | |
| 2173 | - | display: flex; | |
| 2174 | - | align-items: center; | |
| 2175 | - | gap: var(--space-3); | |
| 2176 | - | margin: var(--space-4) 0; | |
| 2177 | - | color: var(--color-text-muted); | |
| 2178 | - | font-size: var(--text-sm); | |
| 2179 | - | } | |
| 2180 | - | .auth-divider::before, | |
| 2181 | - | .auth-divider::after { | |
| 2182 | - | content: ""; | |
| 2183 | - | flex: 1; | |
| 2184 | - | height: 1px; | |
| 2185 | - | background: var(--color-border); | |
| 2186 | - | } | |
| 2187 | - | ||
| 2188 | - | /* --- Passkey UI in settings --- */ | |
| 2189 | - | .passkey-actions { | |
| 2190 | - | display: flex; | |
| 2191 | - | flex-direction: column; | |
| 2192 | - | gap: var(--space-3); | |
| 2193 | - | align-items: flex-start; | |
| 2194 | - | margin-top: var(--space-3); | |
| 2195 | - | } | |
| 2196 | - | .passkey-status { | |
| 2197 | - | font-size: var(--text-sm); | |
| 2198 | - | } | |
| 2199 | - | ||
| 2200 | - | /* --- Danger zone --- */ | |
| 2201 | - | .danger-zone { | |
| 2202 | - | margin-top: var(--space-8); | |
| 2203 | - | } | |
| 2204 | - | .danger-title { | |
| 2205 | - | color: var(--color-danger); | |
| 2206 | - | border-bottom-color: var(--color-danger); | |
| 2207 | - | } | |
| 2208 | - | .danger-card { | |
| 2209 | - | border-color: var(--color-danger); | |
| 2210 | - | background: var(--color-danger-bg); | |
| 2211 | - | } | |
| 2212 | - | .danger-item { | |
| 2213 | - | display: flex; | |
| 2214 | - | align-items: center; | |
| 2215 | - | justify-content: space-between; | |
| 2216 | - | gap: var(--space-4); | |
| 2217 | - | flex-wrap: wrap; | |
| 2218 | - | } | |
| 2219 | - | .danger-item p { | |
| 2220 | - | margin: var(--space-1) 0 0; | |
| 2221 | - | } | |
| 2222 | - | .btn-danger { | |
| 2223 | - | background: var(--color-danger); | |
| 2224 | - | border-color: transparent; | |
| 2225 | - | color: #fff; | |
| 2226 | - | } | |
| 2227 | - | .btn-danger:hover { | |
| 2228 | - | background: color-mix(in srgb, var(--color-danger) 85%, #000); | |
| 2229 | - | } | |
| 2230 | - | ||
| 2231 | - | /* --- Release list items --- */ | |
| 2232 | - | .release-item-header { | |
| 2233 | - | display: flex; | |
| 2234 | - | gap: var(--space-3); | |
| 2235 | - | align-items: flex-start; | |
| 2236 | - | } | |
| 2237 | - | .release-item-main { | |
| 2238 | - | flex: 1; | |
| 2239 | - | min-width: 0; | |
| 2240 | - | } | |
| 2241 | - | .release-item-title { | |
| 2242 | - | font-size: var(--text-lg); | |
| 2243 | - | font-weight: 700; | |
| 2244 | - | text-decoration: none; | |
| 2245 | - | color: var(--color-text); | |
| 2246 | - | display: block; | |
| 2247 | - | margin-bottom: var(--space-1); | |
| 2248 | - | } | |
| 2249 | - | .release-item-title:hover { | |
| 2250 | - | color: var(--color-link); | |
| 2251 | - | } | |
| 2252 | - | .release-item-meta { | |
| 2253 | - | display: flex; | |
| 2254 | - | flex-wrap: wrap; | |
| 2255 | - | gap: var(--space-3); | |
| 2256 | - | align-items: center; | |
| 2257 | - | font-size: var(--text-xs); | |
| 2258 | - | color: var(--color-text-muted); | |
| 2259 | - | } | |
| 2260 | - | .release-item-date { | |
| 2261 | - | display: flex; | |
| 2262 | - | flex-direction: column; | |
| 2263 | - | align-items: flex-end; | |
| 2264 | - | gap: var(--space-2); | |
| 2265 | - | font-size: var(--text-xs); | |
| 2266 | - | color: var(--color-text-muted); | |
| 2267 | - | white-space: nowrap; | |
| 2268 | - | flex-shrink: 0; | |
| 2269 | - | } | |
| 2270 | - | ||
| 2271 | - | /* --- Release notes preview in list --- */ | |
| 2272 | - | .release-notes-section { | |
| 2273 | - | margin-top: var(--space-3); | |
| 2274 | - | } | |
| 2275 | - | .release-notes-label { | |
| 2276 | - | font-size: var(--text-xs); | |
| 2277 | - | font-weight: 600; | |
| 2278 | - | color: var(--color-text-muted); | |
| 2279 | - | text-transform: uppercase; | |
| 2280 | - | letter-spacing: 0.05em; | |
| 2281 | - | margin-bottom: var(--space-2); | |
| 2282 | - | } | |
| 2283 | - | .notes-expand > summary { | |
| 2284 | - | list-style: none; | |
| 2285 | - | cursor: pointer; | |
| 2286 | - | outline: none; | |
| 2287 | - | } | |
| 2288 | - | .notes-expand > summary::-webkit-details-marker { | |
| 2289 | - | display: none; | |
| 2290 | - | } | |
| 2291 | - | .notes-preview { | |
| 2292 | - | max-height: calc(5 * 1.6em); | |
| 2293 | - | overflow: hidden; | |
| 2294 | - | position: relative; | |
| 2295 | - | white-space: pre-wrap; | |
| 2296 | - | font-size: var(--text-sm); | |
| 2297 | - | color: var(--color-text); | |
| 2298 | - | line-height: 1.6; | |
| 2299 | - | word-break: break-word; | |
| 2300 | - | margin-bottom: var(--space-1); | |
| 2301 | - | } | |
| 2302 | - | .notes-preview::after { | |
| 2303 | - | content: ""; | |
| 2304 | - | position: absolute; | |
| 2305 | - | bottom: 0; | |
| 2306 | - | left: 0; | |
| 2307 | - | right: 0; | |
| 2308 | - | height: 2.5em; | |
| 2309 | - | background: linear-gradient(transparent, var(--color-bg)); | |
| 2310 | - | pointer-events: none; | |
| 2311 | - | } | |
| 2312 | - | .notes-expand[open] .notes-preview { | |
| 2313 | - | display: none; | |
| 2314 | - | } | |
| 2315 | - | .notes-full { | |
| 2316 | - | padding-top: var(--space-3); | |
| 2317 | - | padding-bottom: var(--space-1); | |
| 2318 | - | } | |
| 2319 | - | .notes-expand[open] { | |
| 2320 | - | display: flex; | |
| 2321 | - | flex-direction: column; | |
| 2322 | - | } | |
| 2323 | - | .notes-expand[open] > summary { | |
| 2324 | - | order: 1; | |
| 2325 | - | } | |
| 2326 | - | .notes-expand[open] > .notes-full { | |
| 2327 | - | order: 0; | |
| 2328 | - | } | |
| 2329 | - | .notes-toggle-label { | |
| 2330 | - | display: inline-block; | |
| 2331 | - | font-size: var(--text-xs); | |
| 2332 | - | color: var(--color-link); | |
| 2333 | - | margin-top: var(--space-1); | |
| 2334 | - | } | |
| 2335 | - | .notes-toggle-label:hover { | |
| 2336 | - | text-decoration: underline; | |
| 2337 | - | } | |
| 2338 | - | .notes-toggle-label::before { | |
| 2339 | - | content: "Show more ↓"; | |
| 2340 | - | } | |
| 2341 | - | .notes-expand[open] .notes-toggle-label::before { | |
| 2342 | - | content: "Show less ↑"; | |
| 2343 | - | } | |
| 2344 | - | ||
| 2345 | - | /* --- Release detail --- */ | |
| 2346 | - | .release-detail { | |
| 2347 | - | padding: var(--space-6) 0; | |
| 2348 | - | } | |
| 2349 | - | .release-header { | |
| 2350 | - | margin-bottom: var(--space-6); | |
| 2351 | - | } | |
| 2352 | - | .release-notes { | |
| 2353 | - | margin-bottom: var(--space-6); | |
| 2354 | - | } | |
| 2355 | - | .release-assets { | |
| 2356 | - | margin-bottom: var(--space-6); | |
| 2357 | - | } | |
| 2358 | - | .release-assets-heading { | |
| 2359 | - | font-size: var(--text-base); | |
| 2360 | - | font-weight: 600; | |
| 2361 | - | margin-bottom: var(--space-3); | |
| 2362 | - | } | |
| 2363 | - | .asset-list { | |
| 2364 | - | border: 1px solid var(--color-border); | |
| 2365 | - | border-radius: var(--radius-lg); | |
| 2366 | - | overflow: hidden; | |
| 2367 | - | } | |
| 2368 | - | .asset-item { | |
| 2369 | - | display: flex; | |
| 2370 | - | align-items: center; | |
| 2371 | - | gap: var(--space-4); | |
| 2372 | - | padding: var(--space-3) var(--space-4); | |
| 2373 | - | border-bottom: 1px solid var(--color-border-muted); | |
| 2374 | - | font-size: var(--text-sm); | |
| 2375 | - | } | |
| 2376 | - | .asset-item:last-child { | |
| 2377 | - | border-bottom: none; | |
| 2378 | - | } | |
| 2379 | - | .asset-name { | |
| 2380 | - | flex: 1; | |
| 2381 | - | color: var(--color-link); | |
| 2382 | - | text-decoration: none; | |
| 2383 | - | font-family: var(--font-mono); | |
| 2384 | - | font-size: var(--text-xs); | |
| 2385 | - | } | |
| 2386 | - | .asset-name:hover { | |
| 2387 | - | text-decoration: underline; | |
| 2388 | - | } | |
| 2389 | - | .asset-name-pending { | |
| 2390 | - | color: var(--color-text-muted); | |
| 2391 | - | cursor: default; | |
| 2392 | - | } | |
| 2393 | - | .asset-item-pending { | |
| 2394 | - | opacity: 0.6; | |
| 2395 | - | } | |
| 2396 | - | .asset-size, | |
| 2397 | - | .asset-meta { | |
| 2398 | - | color: var(--color-text-muted); | |
| 2399 | - | font-size: var(--text-xs); | |
| 2400 | - | white-space: nowrap; | |
| 2401 | - | } | |
| 2402 | - | .release-back { | |
| 2403 | - | margin-top: var(--space-4); | |
| 2404 | - | display: flex; | |
| 2405 | - | gap: var(--space-3); | |
| 2406 | - | align-items: center; | |
| 2407 | - | justify-content: space-between; | |
| 2408 | - | } | |
| 2409 | - | } | |
| 2410 | - | ||
| 2411 | - | /* ============================================================ | |
| 2412 | - | @layer utilities | |
| 2413 | - | ============================================================ */ | |
| 2414 | - | @layer utilities { | |
| 2415 | - | .text-muted { | |
| 2416 | - | color: var(--color-text-muted); | |
| 2417 | - | } | |
| 2418 | - | .text-sm { | |
| 2419 | - | font-size: var(--text-sm); | |
| 2420 | - | } | |
| 2421 | - | .text-xs { | |
| 2422 | - | font-size: var(--text-xs); | |
| 2423 | - | } | |
| 2424 | - | .font-mono { | |
| 2425 | - | font-family: var(--font-mono); | |
| 2426 | - | } | |
| 2427 | - | .mt-1 { | |
| 2428 | - | margin-top: var(--space-1); | |
| 2429 | - | } | |
| 2430 | - | .mt-2 { | |
| 2431 | - | margin-top: var(--space-2); | |
| 2432 | - | } | |
| 2433 | - | .mt-4 { | |
| 2434 | - | margin-top: var(--space-4); | |
| 2435 | - | } | |
| 2436 | - | .mt-6 { | |
| 2437 | - | margin-top: var(--space-6); | |
| 2438 | - | } | |
| 2439 | - | .mb-4 { | |
| 2440 | - | margin-bottom: var(--space-4); | |
| 2441 | - | } | |
| 2442 | - | .flex { | |
| 2443 | - | display: flex; | |
| 2444 | - | } | |
| 2445 | - | .gap-2 { | |
| 2446 | - | gap: var(--space-2); | |
| 2447 | - | } | |
| 2448 | - | .gap-4 { | |
| 2449 | - | gap: var(--space-4); | |
| 2450 | - | } | |
| 2451 | - | .items-center { | |
| 2452 | - | align-items: center; | |
| 2453 | - | } | |
| 2454 | - | .justify-between { | |
| 2455 | - | justify-content: space-between; | |
| 2456 | - | } | |
| 2457 | - | .hidden { | |
| 2458 | - | display: none; | |
| 2459 | - | } | |
| 2460 | - | } | |
| 2461 | - | ||
| 2462 | - | /* ============================================================ | |
| 2463 | - | Pagination | |
| 2464 | - | ============================================================ */ | |
| 2465 | - | .commit-verify-hint { | |
| 2466 | - | margin-bottom: var(--space-4); | |
| 2467 | - | font-size: var(--text-xs); | |
| 2468 | - | color: var(--color-text-muted); | |
| 2469 | - | } | |
| 2470 | - | .commit-verify-hint code { | |
| 2471 | - | word-break: break-all; | |
| 2472 | - | } | |
| 2473 | - | .commit-cursor-nav { | |
| 2474 | - | display: flex; | |
| 2475 | - | justify-content: space-between; | |
| 2476 | - | border-top: 1px solid var(--color-border); | |
| 2477 | - | margin-top: var(--space-2); | |
| 2478 | - | padding: var(--space-4) 0 var(--space-2); | |
| 2479 | - | } | |
| 2480 | - | ||
| 2481 | - | .commit-cursor-prev { | |
| 2482 | - | flex: 1; | |
| 2483 | - | } | |
| 2484 | - | .commit-cursor-next { | |
| 2485 | - | flex: 1; | |
| 2486 | - | text-align: right; | |
| 2487 | - | } | |
| 2488 | - | ||
| 2489 | - | .pagination { | |
| 2490 | - | display: grid; | |
| 2491 | - | grid-template-columns: 1fr auto 1fr; | |
| 2492 | - | align-items: center; | |
| 2493 | - | border-top: 1px solid var(--color-border); | |
| 2494 | - | margin-top: var(--space-2); | |
| 2495 | - | padding: var(--space-4) 0 var(--space-2); | |
| 2496 | - | } | |
| 2497 | - | ||
| 2498 | - | .pagination-prev { | |
| 2499 | - | justify-self: start; | |
| 2500 | - | } | |
| 2501 | - | .pagination-next { | |
| 2502 | - | justify-self: end; | |
| 2503 | - | } | |
| 2504 | - | ||
| 2505 | - | .pagination-info { | |
| 2506 | - | justify-self: center; | |
| 2507 | - | font-size: var(--text-sm); | |
| 2508 | - | color: var(--color-text-muted); | |
| 2509 | - | } | |
| 2510 | - | ||
| 2511 | - | .pagination-btn { | |
| 2512 | - | display: inline-flex; | |
| 2513 | - | align-items: center; | |
| 2514 | - | gap: var(--space-1); | |
| 2515 | - | padding: var(--space-1) var(--space-3); | |
| 2516 | - | border: 1px solid var(--color-border); | |
| 2517 | - | border-radius: var(--radius-md); | |
| 2518 | - | font-size: var(--text-sm); | |
| 2519 | - | font-weight: 500; | |
| 2520 | - | text-decoration: none; | |
| 2521 | - | color: var(--color-text); | |
| 2522 | - | background: var(--color-bg-subtle); | |
| 2523 | - | transition: | |
| 2524 | - | background 0.1s, | |
| 2525 | - | border-color 0.1s; | |
| 2526 | - | white-space: nowrap; | |
| 2527 | - | } | |
| 2528 | - | ||
| 2529 | - | .pagination-btn:hover { | |
| 2530 | - | background: var(--color-bg-inset); | |
| 2531 | - | border-color: var(--color-border); | |
| 2532 | - | color: var(--color-text); | |
| 2533 | - | text-decoration: none; | |
| 2534 | - | } | |
| 2535 | - | ||
| 2536 | - | /* Settings */ | |
| 2537 | - | .theme-options { | |
| 2538 | - | display: flex; | |
| 2539 | - | gap: var(--space-4); | |
| 2540 | - | margin-bottom: var(--space-4); | |
| 2541 | - | flex-wrap: wrap; | |
| 2542 | - | } | |
| 2543 | - | .theme-option { | |
| 2544 | - | display: flex; | |
| 2545 | - | align-items: center; | |
| 2546 | - | gap: var(--space-2); | |
| 2547 | - | cursor: pointer; | |
| 2548 | - | font-size: var(--text-sm); | |
| 2549 | - | } | |
| 2550 | - | .settings-form { | |
| 2551 | - | display: flex; | |
| 2552 | - | flex-direction: column; | |
| 2553 | - | gap: var(--space-4); | |
| 2554 | - | } | |
| 2555 | - | .passkey-list { | |
| 2556 | - | display: flex; | |
| 2557 | - | flex-direction: column; | |
| 2558 | - | gap: var(--space-2); | |
| 2559 | - | margin-bottom: var(--space-4); | |
| 2560 | - | } | |
| 2561 | - | .passkey-item { | |
| 2562 | - | display: flex; | |
| 2563 | - | align-items: center; | |
| 2564 | - | gap: var(--space-3); | |
| 2565 | - | padding: var(--space-2) 0; | |
| 2566 | - | border-bottom: 1px solid var(--color-border-muted); | |
| 2567 | - | font-size: var(--text-sm); | |
| 2568 | - | } | |
| 2569 | - | .passkey-item:last-child { | |
| 2570 | - | border-bottom: none; | |
| 2571 | - | } | |
| 2572 | - | .passkey-date { | |
| 2573 | - | flex: 1; | |
| 2574 | - | color: var(--color-text-muted); | |
| 2575 | - | } | |
| 2576 | - | .queue-bulk-actions { | |
| 2577 | - | display: flex; | |
| 2578 | - | gap: var(--space-2); | |
| 2579 | - | margin-bottom: var(--space-4); | |
| 2580 | - | } | |
| 2581 | - | .queue-list { | |
| 2582 | - | list-style: none; | |
| 2583 | - | padding: 0; | |
| 2584 | - | margin: 0; | |
| 2585 | - | max-height: 32rem; | |
| 2586 | - | overflow-y: auto; | |
| 2587 | - | } | |
| 2588 | - | .queue-item { | |
| 2589 | - | display: flex; | |
| 2590 | - | align-items: flex-start; | |
| 2591 | - | justify-content: space-between; | |
| 2592 | - | gap: var(--space-4); | |
| 2593 | - | padding: var(--space-3) 0; | |
| 2594 | - | border-top: 1px solid var(--color-border-muted); | |
| 2595 | - | font-size: var(--text-sm); | |
| 2596 | - | } | |
| 2597 | - | .queue-item-meta { | |
| 2598 | - | display: flex; | |
| 2599 | - | flex-direction: column; | |
| 2600 | - | gap: var(--space-1); | |
| 2601 | - | min-width: 0; | |
| 2602 | - | } | |
| 2603 | - | .queue-item-header { | |
| 2604 | - | display: flex; | |
| 2605 | - | align-items: baseline; | |
| 2606 | - | gap: var(--space-2); | |
| 2607 | - | } | |
| 2608 | - | .queue-item-date { | |
| 2609 | - | color: var(--color-text-muted); | |
| 2610 | - | font-size: var(--text-xs); | |
| 2611 | - | } | |
| 2612 | - | .queue-item-answer { | |
| 2613 | - | margin: 0; | |
| 2614 | - | color: var(--color-text-muted); | |
| 2615 | - | font-size: var(--text-xs); | |
| 2616 | - | white-space: pre-wrap; | |
| 2617 | - | word-break: break-word; | |
| 2618 | - | } | |
| 2619 | - | .queue-item-actions { | |
| 2620 | - | display: flex; | |
| 2621 | - | gap: var(--space-2); | |
| 2622 | - | flex-shrink: 0; | |
| 2623 | - | } | |
| 2624 | - | .ssh-key-info { | |
| 2625 | - | flex: 1; | |
| 2626 | - | display: flex; | |
| 2627 | - | flex-direction: column; | |
| 2628 | - | gap: var(--space-1); | |
| 2629 | - | min-width: 0; | |
| 2630 | - | } | |
| 2631 | - | .ssh-key-name { | |
| 2632 | - | font-weight: 500; | |
| 2633 | - | } | |
| 2634 | - | .ssh-key-fingerprint { | |
| 2635 | - | font-family: var(--font-mono); | |
| 2636 | - | font-size: var(--text-xs); | |
| 2637 | - | overflow: hidden; | |
| 2638 | - | text-overflow: ellipsis; | |
| 2639 | - | white-space: nowrap; | |
| 2640 | - | } | |
| 2641 | - | .form-textarea { | |
| 2642 | - | resize: vertical; | |
| 2643 | - | min-height: 4.5rem; | |
| 2644 | - | font-family: var(--font-mono); | |
| 2645 | - | font-size: var(--text-sm); | |
| 2646 | - | } | |
| 2647 | - | /* Clone popup */ | |
| 2648 | - | .clone-popup-details { | |
| 2649 | - | position: relative; | |
| 2650 | - | display: inline-block; | |
| 2651 | - | } | |
| 2652 | - | .clone-popup-details > summary { | |
| 2653 | - | list-style: none; | |
| 2654 | - | } | |
| 2655 | - | .clone-popup-details > summary::-webkit-details-marker { | |
| 2656 | - | display: none; | |
| 2657 | - | } | |
| 2658 | - | .clone-popup { | |
| 2659 | - | position: absolute; | |
| 2660 | - | right: 1rem; | |
| 2661 | - | top: calc(100% + var(--space-2)); | |
| 2662 | - | background: var(--color-bg); | |
| 2663 | - | border: 1px solid var(--color-border); | |
| 2664 | - | border-radius: var(--radius-lg); | |
| 2665 | - | padding: var(--space-3); | |
| 2666 | - | min-width: 22rem; | |
| 2667 | - | z-index: 100; | |
| 2668 | - | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); | |
| 2669 | - | display: flex; | |
| 2670 | - | flex-direction: column; | |
| 2671 | - | gap: var(--space-2); | |
| 2672 | - | } | |
| 2673 | - | .clone-url-row { | |
| 2674 | - | display: flex; | |
| 2675 | - | align-items: center; | |
| 2676 | - | gap: var(--space-2); | |
| 2677 | - | } | |
| 2678 | - | .clone-url-label { | |
| 2679 | - | font-size: var(--text-xs); | |
| 2680 | - | font-weight: 600; | |
| 2681 | - | color: var(--color-text-muted); | |
| 2682 | - | text-transform: uppercase; | |
| 2683 | - | letter-spacing: 0.05em; | |
| 2684 | - | min-width: 2.75rem; | |
| 2685 | - | } | |
| 2686 | - | .clone-url-input { | |
| 2687 | - | flex: 1; | |
| 2688 | - | font-family: var(--font-mono); | |
| 2689 | - | font-size: var(--text-sm); | |
| 2690 | - | background: var(--color-bg-subtle); | |
| 2691 | - | border: 1px solid var(--color-border-muted); | |
| 2692 | - | border-radius: var(--radius-md); | |
| 2693 | - | padding: var(--space-1) var(--space-2); | |
| 2694 | - | color: var(--color-text); | |
| 2695 | - | cursor: text; | |
| 2696 | - | min-width: 0; | |
| 2697 | - | } | |
| 2698 | - | .user-list { | |
| 2699 | - | display: flex; | |
| 2700 | - | flex-direction: column; | |
| 2701 | - | gap: var(--space-2); | |
| 2702 | - | margin-bottom: var(--space-6); | |
| 2703 | - | } | |
| 2704 | - | .user-item { | |
| 2705 | - | display: flex; | |
| 2706 | - | align-items: center; | |
| 2707 | - | gap: var(--space-3); | |
| 2708 | - | padding: var(--space-2) 0; | |
| 2709 | - | border-bottom: 1px solid var(--color-border-muted); | |
| 2710 | - | font-size: var(--text-sm); | |
| 2711 | - | } | |
| 2712 | - | .user-item:last-child { | |
| 2713 | - | border-bottom: none; | |
| 2714 | - | } | |
| 2715 | - | .user-name { | |
| 2716 | - | font-weight: 500; | |
| 2717 | - | flex: 1; | |
| 2718 | - | } | |
| 2719 | - | .user-date { | |
| 2720 | - | color: var(--color-text-muted); | |
| 2721 | - | font-size: var(--text-xs); | |
| 2722 | - | } | |
| 2723 | - | ||
| 2724 | - | /* Labels */ | |
| 2725 | - | .label-badge { | |
| 2726 | - | display: inline-block; | |
| 2727 | - | padding: 0 var(--space-2); | |
| 2728 | - | border-radius: 2em; | |
| 2729 | - | font-size: var(--text-xs); | |
| 2730 | - | font-weight: 500; | |
| 2731 | - | line-height: 1.6; | |
| 2732 | - | word-break: break-all; | |
| 2733 | - | } | |
| 2734 | - | .issue-labels { | |
| 2735 | - | display: flex; | |
| 2736 | - | flex-wrap: wrap; | |
| 2737 | - | gap: var(--space-1); | |
| 2738 | - | margin-bottom: var(--space-2); | |
| 2739 | - | } | |
| 2740 | - | .issue-labels-row { | |
| 2741 | - | display: flex; | |
| 2742 | - | flex-wrap: wrap; | |
| 2743 | - | align-items: center; | |
| 2744 | - | gap: var(--space-2); | |
| 2745 | - | padding: var(--space-2) 0 var(--space-3); | |
| 2746 | - | } | |
| 2747 | - | .label-badge-wrap { | |
| 2748 | - | display: inline-flex; | |
| 2749 | - | align-items: center; | |
| 2750 | - | gap: var(--space-1); | |
| 2751 | - | } | |
| 2752 | - | .label-remove-form { | |
| 2753 | - | display: inline; | |
| 2754 | - | } | |
| 2755 | - | .label-remove-btn { | |
| 2756 | - | background: none; | |
| 2757 | - | border: none; | |
| 2758 | - | cursor: pointer; | |
| 2759 | - | padding: 0 2px; | |
| 2760 | - | font-size: var(--text-sm); | |
| 2761 | - | line-height: 1; | |
| 2762 | - | color: var(--color-text-muted); | |
| 2763 | - | opacity: 0.7; | |
| 2764 | - | } | |
| 2765 | - | .label-remove-btn:hover { | |
| 2766 | - | opacity: 1; | |
| 2767 | - | } | |
| 2768 | - | .label-add-inline-form { | |
| 2769 | - | display: inline-flex; | |
| 2770 | - | align-items: center; | |
| 2771 | - | gap: var(--space-2); | |
| 2772 | - | } | |
| 2773 | - | .label-select { | |
| 2774 | - | font-size: var(--text-sm); | |
| 2775 | - | padding: var(--space-1) var(--space-2); | |
| 2776 | - | border: 1px solid var(--color-border); | |
| 2777 | - | border-radius: var(--radius-md); | |
| 2778 | - | background: var(--color-bg); | |
| 2779 | - | color: var(--color-text); | |
| 2780 | - | height: 2rem; | |
| 2781 | - | } | |
| 2782 | - | .label-checkbox-list { | |
| 2783 | - | display: flex; | |
| 2784 | - | flex-wrap: wrap; | |
| 2785 | - | gap: var(--space-2); | |
| 2786 | - | margin-top: var(--space-1); | |
| 2787 | - | } | |
| 2788 | - | .label-checkbox-item { | |
| 2789 | - | display: inline-flex; | |
| 2790 | - | align-items: center; | |
| 2791 | - | gap: var(--space-1); | |
| 2792 | - | cursor: pointer; | |
| 2793 | - | } | |
| 2794 | - | .label-settings-list { | |
| 2795 | - | display: flex; | |
| 2796 | - | flex-direction: column; | |
| 2797 | - | margin-bottom: var(--space-4); | |
| 2798 | - | max-height: 24rem; | |
| 2799 | - | overflow-y: auto; | |
| 2800 | - | } | |
| 2801 | - | .label-settings-item { | |
| 2802 | - | display: flex; | |
| 2803 | - | align-items: center; | |
| 2804 | - | gap: var(--space-3); | |
| 2805 | - | padding: var(--space-2) 0; | |
| 2806 | - | border-bottom: 1px solid var(--color-border-muted); | |
| 2807 | - | font-size: var(--text-sm); | |
| 2808 | - | } | |
| 2809 | - | .label-settings-item:last-child { | |
| 2810 | - | border-bottom: none; | |
| 2811 | - | } | |
| 2812 | - | .label-settings-swatch { | |
| 2813 | - | width: 12px; | |
| 2814 | - | height: 12px; | |
| 2815 | - | border-radius: 50%; | |
| 2816 | - | flex-shrink: 0; | |
| 2817 | - | border: 1px solid rgba(0, 0, 0, 0.15); | |
| 2818 | - | } | |
| 2819 | - | .label-settings-name { | |
| 2820 | - | flex: 1; | |
| 2821 | - | font-weight: 500; | |
| 2822 | - | } | |
| 2823 | - | .label-add-form { | |
| 2824 | - | display: flex; | |
| 2825 | - | align-items: center; | |
| 2826 | - | gap: var(--space-2); | |
| 2827 | - | } | |
| 2828 | - | .label-name-input { | |
| 2829 | - | flex: 1; | |
| 2830 | - | min-width: 0; | |
| 2831 | - | padding: var(--space-2) var(--space-3); | |
| 2832 | - | border: 1px solid var(--color-border); | |
| 2833 | - | border-radius: var(--radius-md); | |
| 2834 | - | background: var(--color-bg); | |
| 2835 | - | color: var(--color-text); | |
| 2836 | - | font-size: var(--text-sm); | |
| 2837 | - | height: 2rem; | |
| 2838 | - | box-sizing: border-box; | |
| 2839 | - | } | |
| 2840 | - | .label-name-input:focus { | |
| 2841 | - | outline: 2px solid var(--color-accent); | |
| 2842 | - | outline-offset: -1px; | |
| 2843 | - | border-color: var(--color-accent); | |
| 2844 | - | } | |
| 2845 | - | .label-color-swatch-label { | |
| 2846 | - | flex-shrink: 0; | |
| 2847 | - | cursor: pointer; | |
| 2848 | - | display: block; | |
| 2849 | - | line-height: 0; | |
| 2850 | - | } | |
| 2851 | - | .label-color-input { | |
| 2852 | - | display: block; | |
| 2853 | - | width: 2rem; | |
| 2854 | - | height: 2rem; | |
| 2855 | - | padding: 2px; | |
| 2856 | - | border: 1px solid var(--color-border); | |
| 2857 | - | border-radius: var(--radius-md); | |
| 2858 | - | background: var(--color-bg); | |
| 2859 | - | cursor: pointer; | |
| 2860 | - | -webkit-appearance: none; | |
| 2861 | - | appearance: none; | |
| 2862 | - | box-sizing: border-box; | |
| 2863 | - | } | |
| 2864 | - | ||
| 2865 | - | /* Label filter (details/summary popup) */ | |
| 2866 | - | .list-header-actions { | |
| 2867 | - | display: flex; | |
| 2868 | - | align-items: center; | |
| 2869 | - | gap: var(--space-2); | |
| 2870 | - | flex-shrink: 0; | |
| 2871 | - | } | |
| 2872 | - | .label-filter { | |
| 2873 | - | position: relative; | |
| 2874 | - | } | |
| 2875 | - | .label-filter-popup { | |
| 2876 | - | position: absolute; | |
| 2877 | - | right: 0; | |
| 2878 | - | top: calc(100% + var(--space-1)); | |
| 2879 | - | z-index: 10; | |
| 2880 | - | background: var(--color-bg); | |
| 2881 | - | border: 1px solid var(--color-border); | |
| 2882 | - | border-radius: var(--radius-lg); | |
| 2883 | - | padding: var(--space-3); | |
| 2884 | - | min-width: 180px; | |
| 2885 | - | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); | |
| 2886 | - | } | |
| 2887 | - | .label-filter-list { | |
| 2888 | - | display: flex; | |
| 2889 | - | flex-direction: column; | |
| 2890 | - | gap: var(--space-2); | |
| 2891 | - | margin-bottom: var(--space-3); | |
| 2892 | - | max-height: 16rem; | |
| 2893 | - | overflow-y: auto; | |
| 2894 | - | } | |
| 2895 | - | .label-filter-item { | |
| 2896 | - | display: flex; | |
| 2897 | - | align-items: center; | |
| 2898 | - | gap: var(--space-2); | |
| 2899 | - | cursor: pointer; | |
| 2900 | - | font-size: var(--text-sm); | |
| 2901 | - | } | |
| 2902 | - | .label-filter-actions { | |
| 2903 | - | display: flex; | |
| 2904 | - | gap: var(--space-2); | |
| 2905 | - | } | |
| 4 | + | @import "./base.css"; | |
| 5 | + | @import "./components.css"; | |
| 6 | + | @import "./code.css"; | |
| 7 | + | @import "./utilities.css"; | |
Asrc/styles/utilities.css
| @@ -0,0 +1,47 @@ | |||
|---|---|---|---|
| 1 | + | @layer utilities { | |
| 2 | + | .text-muted { | |
| 3 | + | color: var(--color-text-muted); | |
| 4 | + | } | |
| 5 | + | .text-sm { | |
| 6 | + | font-size: var(--text-sm); | |
| 7 | + | } | |
| 8 | + | .text-xs { | |
| 9 | + | font-size: var(--text-xs); | |
| 10 | + | } | |
| 11 | + | .font-mono { | |
| 12 | + | font-family: var(--font-mono); | |
| 13 | + | } | |
| 14 | + | .mt-1 { | |
| 15 | + | margin-top: var(--space-1); | |
| 16 | + | } | |
| 17 | + | .mt-2 { | |
| 18 | + | margin-top: var(--space-2); | |
| 19 | + | } | |
| 20 | + | .mt-4 { | |
| 21 | + | margin-top: var(--space-4); | |
| 22 | + | } | |
| 23 | + | .mt-6 { | |
| 24 | + | margin-top: var(--space-6); | |
| 25 | + | } | |
| 26 | + | .mb-4 { | |
| 27 | + | margin-bottom: var(--space-4); | |
| 28 | + | } | |
| 29 | + | .flex { | |
| 30 | + | display: flex; | |
| 31 | + | } | |
| 32 | + | .gap-2 { | |
| 33 | + | gap: var(--space-2); | |
| 34 | + | } | |
| 35 | + | .gap-4 { | |
| 36 | + | gap: var(--space-4); | |
| 37 | + | } | |
| 38 | + | .items-center { | |
| 39 | + | align-items: center; | |
| 40 | + | } | |
| 41 | + | .justify-between { | |
| 42 | + | justify-content: space-between; | |
| 43 | + | } | |
| 44 | + | .hidden { | |
| 45 | + | display: none; | |
| 46 | + | } | |
| 47 | + | } | |
Msrc/views/layout.tsx
| @@ -1,4 +1,4 @@ | |||
|---|---|---|---|
| 1 | - | import { OWNER_DISPLAY_NAME } from "../config.ts"; | |
| 1 | + | import config from "../config.ts"; | |
| 2 | 2 | import { displayName } from "../lib/users.ts"; | |
| 3 | 3 | import type { SessionUser } from "../middleware/session.ts"; | |
| 4 | 4 | import { Avatar } from "./Avatar.tsx"; | |
| @@ -80,7 +80,7 @@ export function Layout({ user, title, children }: LayoutProps) { | |||
|---|---|---|---|
| 80 | 80 | </header> | |
| 81 | 81 | <main class="site-main">{children}</main> | |
| 82 | 82 | <footer class="site-footer"> | |
| 83 | - | <p>Hearthforge — hosted by {OWNER_DISPLAY_NAME}</p> | |
| 83 | + | <p>Hearthforge — hosted by {config.OWNER_DISPLAY_NAME}</p> | |
| 84 | 84 | </footer> | |
| 85 | 85 | </body> | |
| 86 | 86 | </html> | |
Msrc/views/repos/RepoHome.tsx
| @@ -1,4 +1,4 @@ | |||
|---|---|---|---|
| 1 | - | import { BASE_URL, SSH_DISABLED, SSH_PORT } from "../../config.ts"; | |
| 1 | + | import config from "../../config.ts"; | |
| 2 | 2 | import type { RepositoryRow } from "../../db/index.ts"; | |
| 3 | 3 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 4 | 4 | import type { TreeEntry } from "../../services/git.ts"; | |
| @@ -73,7 +73,7 @@ git push origin main`}</code> | |||
|---|---|---|---|
| 73 | 73 | class="clone-url-input" | |
| 74 | 74 | /> | |
| 75 | 75 | </div> | |
| 76 | - | {!SSH_DISABLED && ( | |
| 76 | + | {!config.SSH_DISABLED && ( | |
| 77 | 77 | <div class="clone-url-row"> | |
| 78 | 78 | <span class="clone-url-label"> | |
| 79 | 79 | SSH | |
| @@ -119,10 +119,10 @@ git push origin main`}</code> | |||
|---|---|---|---|
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | function httpUrl(name: string) { | |
| 122 | - | return `${BASE_URL}/${name}.git`; | |
| 122 | + | return `${config.BASE_URL}/${name}.git`; | |
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | 125 | function sshUrl(name: string) { | |
| 126 | - | const host = new URL(BASE_URL).hostname; | |
| 127 | - | return `ssh://git@${host}:${SSH_PORT}/${name}.git`; | |
| 126 | + | const host = new URL(config.BASE_URL).hostname; | |
| 127 | + | return `ssh://git@${host}:${config.SSH_PORT}/${name}.git`; | |
| 128 | 128 | } | |
Atests/e2e.auth.test.ts
| @@ -0,0 +1,112 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser } from 'playwright'; | |
| 4 | + | import { | |
| 5 | + | BASE, | |
| 6 | + | ADMIN_PASS, | |
| 7 | + | setupTestEnv, | |
| 8 | + | spawnServer, | |
| 9 | + | killServer, | |
| 10 | + | login, | |
| 11 | + | logout, | |
| 12 | + | } from './helpers.ts'; | |
| 13 | + | ||
| 14 | + | let browser: Browser; | |
| 15 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 16 | + | ||
| 17 | + | beforeAll(async () => { | |
| 18 | + | await setupTestEnv(); | |
| 19 | + | server = await spawnServer(); | |
| 20 | + | browser = await chromium.launch(); | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | afterAll(async () => { | |
| 24 | + | await browser.close(); | |
| 25 | + | await killServer(server); | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | // ─── Auth ───────────────────────────────────────────────────────────────────── | |
| 29 | + | ||
| 30 | + | describe('auth', () => { | |
| 31 | + | test('homepage loads', async () => { | |
| 32 | + | const ctx = await browser.newContext(); | |
| 33 | + | const page = await ctx.newPage(); | |
| 34 | + | try { | |
| 35 | + | await page.goto(BASE); | |
| 36 | + | expect(await page.title()).toContain('Hearthforge'); | |
| 37 | + | } finally { await ctx.close(); } | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test('wrong password shows error', async () => { | |
| 41 | + | const ctx = await browser.newContext(); | |
| 42 | + | const page = await ctx.newPage(); | |
| 43 | + | try { | |
| 44 | + | await page.goto(`${BASE}/login`); | |
| 45 | + | await page.fill('[name=username]', 'admin'); | |
| 46 | + | await page.fill('[name=password]', 'wrongpassword'); | |
| 47 | + | await page.click('button[type=submit]'); | |
| 48 | + | expect(await page.locator('.form-error').textContent()).toContain('Invalid'); | |
| 49 | + | } finally { await ctx.close(); } | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | test('correct credentials redirect to homepage', async () => { | |
| 53 | + | const ctx = await browser.newContext(); | |
| 54 | + | const page = await ctx.newPage(); | |
| 55 | + | try { | |
| 56 | + | await login(page); | |
| 57 | + | expect(page.url()).toBe(BASE + '/'); | |
| 58 | + | expect(await page.locator('.nav-user').isVisible()).toBe(true); | |
| 59 | + | } finally { await ctx.close(); } | |
| 60 | + | }); | |
| 61 | + | ||
| 62 | + | test('register new user', async () => { | |
| 63 | + | const ctx = await browser.newContext(); | |
| 64 | + | const page = await ctx.newPage(); | |
| 65 | + | try { | |
| 66 | + | await page.goto(`${BASE}/register`); | |
| 67 | + | await page.fill('[name=username]', 'alice'); | |
| 68 | + | await page.fill('[name=password]', 'password123'); | |
| 69 | + | await page.fill('[name=password2]', 'password123'); | |
| 70 | + | await page.click('button[type=submit]'); | |
| 71 | + | await page.waitForURL(BASE + '/'); | |
| 72 | + | expect(await page.locator('.nav-user').textContent()).toBe('alice'); | |
| 73 | + | } finally { await ctx.close(); } | |
| 74 | + | }); | |
| 75 | + | ||
| 76 | + | test('register with mismatched passwords shows error', async () => { | |
| 77 | + | const ctx = await browser.newContext(); | |
| 78 | + | const page = await ctx.newPage(); | |
| 79 | + | try { | |
| 80 | + | await page.goto(`${BASE}/register`); | |
| 81 | + | await page.fill('[name=username]', 'bob'); | |
| 82 | + | await page.fill('[name=password]', 'password123'); | |
| 83 | + | await page.fill('[name=password2]', 'different456'); | |
| 84 | + | await page.click('button[type=submit]'); | |
| 85 | + | expect(await page.locator('.form-error').textContent()).toContain('match'); | |
| 86 | + | } finally { await ctx.close(); } | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | test('register with duplicate username shows error', async () => { | |
| 90 | + | const ctx = await browser.newContext(); | |
| 91 | + | const page = await ctx.newPage(); | |
| 92 | + | try { | |
| 93 | + | await page.goto(`${BASE}/register`); | |
| 94 | + | await page.fill('[name=username]', 'alice'); // already registered above | |
| 95 | + | await page.fill('[name=password]', 'password123'); | |
| 96 | + | await page.fill('[name=password2]', 'password123'); | |
| 97 | + | await page.click('button[type=submit]'); | |
| 98 | + | expect(await page.locator('.form-error').textContent()).toContain('taken'); | |
| 99 | + | } finally { await ctx.close(); } | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | test('logout clears session', async () => { | |
| 103 | + | const ctx = await browser.newContext(); | |
| 104 | + | const page = await ctx.newPage(); | |
| 105 | + | try { | |
| 106 | + | await login(page); | |
| 107 | + | await logout(page); | |
| 108 | + | expect(await page.locator('.nav-user').count()).toBe(0); | |
| 109 | + | expect(await page.locator('a[href="/login"]').isVisible()).toBe(true); | |
| 110 | + | } finally { await ctx.close(); } | |
| 111 | + | }); | |
| 112 | + | }); | |
Atests/e2e.file-editing.test.ts
| @@ -0,0 +1,157 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { | |
| 5 | + | BASE, | |
| 6 | + | DATA_DIR, | |
| 7 | + | ADMIN_PASS, | |
| 8 | + | setupTestEnv, | |
| 9 | + | spawnServer, | |
| 10 | + | killServer, | |
| 11 | + | login, | |
| 12 | + | seedRepo, | |
| 13 | + | getHeadCommit, | |
| 14 | + | gitOutput, | |
| 15 | + | } from './helpers.ts'; | |
| 16 | + | ||
| 17 | + | let browser: Browser; | |
| 18 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 19 | + | ||
| 20 | + | beforeAll(async () => { | |
| 21 | + | await setupTestEnv(); | |
| 22 | + | server = await spawnServer(); | |
| 23 | + | browser = await chromium.launch(); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | afterAll(async () => { | |
| 27 | + | await browser.close(); | |
| 28 | + | await killServer(server); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 32 | + | const ctx = await browser.newContext(); | |
| 33 | + | const page = await ctx.newPage(); | |
| 34 | + | await login(page, username, password); | |
| 35 | + | await page.close(); | |
| 36 | + | return ctx; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ─── File editing ───────────────────────────────────────────────────────────── | |
| 40 | + | ||
| 41 | + | describe('file editing', () => { | |
| 42 | + | let adminCtx: BrowserContext; | |
| 43 | + | ||
| 44 | + | beforeAll(async () => { | |
| 45 | + | adminCtx = await loggedInContext(); | |
| 46 | + | // Create a dedicated repo so edits don't interfere with other tests | |
| 47 | + | const page = await adminCtx.newPage(); | |
| 48 | + | try { | |
| 49 | + | await page.goto(`${BASE}/new`); | |
| 50 | + | await page.fill('[name=name]', 'edit-repo'); | |
| 51 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 52 | + | await page.waitForURL(`${BASE}/edit-repo`); | |
| 53 | + | } finally { await page.close(); } | |
| 54 | + | await seedRepo('edit-repo'); | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 58 | + | ||
| 59 | + | test('Edit button appears on text file blob when viewing a branch as admin', async () => { | |
| 60 | + | const page = await adminCtx.newPage(); | |
| 61 | + | try { | |
| 62 | + | await page.goto(`${BASE}/edit-repo/blob/main/index.js`); | |
| 63 | + | const editBtn = page.locator('a[href*="/edit/main/index.js"]'); | |
| 64 | + | expect(await editBtn.isVisible()).toBe(true); | |
| 65 | + | expect(await editBtn.textContent()).toBe('Edit'); | |
| 66 | + | } finally { await page.close(); } | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | test('Edit button does not appear when viewing a commit SHA', async () => { | |
| 70 | + | const sha = await getHeadCommit('edit-repo'); | |
| 71 | + | const page = await adminCtx.newPage(); | |
| 72 | + | try { | |
| 73 | + | await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`); | |
| 74 | + | expect(await page.locator('a[href*="/edit/"]').count()).toBe(0); | |
| 75 | + | } finally { await page.close(); } | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test('Edit button does not appear for unauthenticated visitors', async () => { | |
| 79 | + | const ctx = await browser.newContext(); | |
| 80 | + | const page = await ctx.newPage(); | |
| 81 | + | try { | |
| 82 | + | await page.goto(`${BASE}/edit-repo/blob/main/index.js`); | |
| 83 | + | expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0); | |
| 84 | + | } finally { | |
| 85 | + | await page.close(); | |
| 86 | + | await ctx.close(); | |
| 87 | + | } | |
| 88 | + | }); | |
| 89 | + | ||
| 90 | + | test('edit page loads with file content pre-filled', async () => { | |
| 91 | + | const page = await adminCtx.newPage(); | |
| 92 | + | try { | |
| 93 | + | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 94 | + | expect(await page.locator('.file-blob-name').textContent()).toBe('index.js'); | |
| 95 | + | const content = await page.locator('textarea[name=content]').inputValue(); | |
| 96 | + | expect(content).toContain('hello'); | |
| 97 | + | const msg = await page.locator('textarea[name=message]').inputValue(); | |
| 98 | + | expect(msg).toBe('Edited index.js'); | |
| 99 | + | } finally { await page.close(); } | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | test('edit page shows which branch will be committed to', async () => { | |
| 103 | + | const page = await adminCtx.newPage(); | |
| 104 | + | try { | |
| 105 | + | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 106 | + | expect(await page.content()).toContain('main'); | |
| 107 | + | } finally { await page.close(); } | |
| 108 | + | }); | |
| 109 | + | ||
| 110 | + | test('edit page returns 404 for non-branch ref', async () => { | |
| 111 | + | const sha = await getHeadCommit('edit-repo'); | |
| 112 | + | const page = await adminCtx.newPage(); | |
| 113 | + | try { | |
| 114 | + | const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`); | |
| 115 | + | expect(resp.status()).toBe(404); | |
| 116 | + | } finally { await page.close(); } | |
| 117 | + | }); | |
| 118 | + | ||
| 119 | + | test('submitting edit creates a new commit and redirects to blob view', async () => { | |
| 120 | + | const page = await adminCtx.newPage(); | |
| 121 | + | try { | |
| 122 | + | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 123 | + | await page.fill('textarea[name=content]', 'console.log("edited");\n'); | |
| 124 | + | await page.fill('textarea[name=message]', 'Update index.js via web editor'); | |
| 125 | + | await page.locator('.form-actions button[type=submit]').click(); | |
| 126 | + | await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/); | |
| 127 | + | // The commit detail view should show the commit message | |
| 128 | + | expect(await page.content()).toContain('Update index.js via web editor'); | |
| 129 | + | } finally { await page.close(); } | |
| 130 | + | }); | |
| 131 | + | ||
| 132 | + | test('edit commit has a gpgsig header (is signed)', async () => { | |
| 133 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`; | |
| 134 | + | const hash = gitOutput(['log', '--format=%H', '--grep=Update index.js via web editor', '-1'], repoDir); | |
| 135 | + | expect(hash).toBeTruthy(); | |
| 136 | + | const obj = gitOutput(['cat-file', '-p', hash], repoDir); | |
| 137 | + | expect(obj).toContain('gpgsig'); | |
| 138 | + | }); | |
| 139 | + | ||
| 140 | + | test('edit commit shows verified badge in commit log', async () => { | |
| 141 | + | const page = await adminCtx.newPage(); | |
| 142 | + | try { | |
| 143 | + | await page.goto(`${BASE}/edit-repo/commits/main`); | |
| 144 | + | const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' }); | |
| 145 | + | expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 146 | + | } finally { await page.close(); } | |
| 147 | + | }); | |
| 148 | + | ||
| 149 | + | test('GET edit page returns 404 for non-branch ref', async () => { | |
| 150 | + | const sha = await getHeadCommit('edit-repo'); | |
| 151 | + | const page = await adminCtx.newPage(); | |
| 152 | + | try { | |
| 153 | + | const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`); | |
| 154 | + | expect(resp.status()).toBe(404); | |
| 155 | + | } finally { await page.close(); } | |
| 156 | + | }); | |
| 157 | + | }); | |
Atests/e2e.issues.test.ts
| @@ -0,0 +1,446 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { | |
| 5 | + | BASE, | |
| 6 | + | ADMIN_PASS, | |
| 7 | + | setupTestEnv, | |
| 8 | + | spawnServer, | |
| 9 | + | killServer, | |
| 10 | + | login, | |
| 11 | + | seedRepo, | |
| 12 | + | } from './helpers.ts'; | |
| 13 | + | ||
| 14 | + | let browser: Browser; | |
| 15 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 16 | + | ||
| 17 | + | beforeAll(async () => { | |
| 18 | + | await setupTestEnv(); | |
| 19 | + | server = await spawnServer(); | |
| 20 | + | browser = await chromium.launch(); | |
| 21 | + | ||
| 22 | + | // Register alice | |
| 23 | + | const regCtx = await browser.newContext(); | |
| 24 | + | const regPage = await regCtx.newPage(); | |
| 25 | + | try { | |
| 26 | + | await regPage.goto(`${BASE}/register`); | |
| 27 | + | await regPage.fill('[name=username]', 'alice'); | |
| 28 | + | await regPage.fill('[name=password]', 'password123'); | |
| 29 | + | await regPage.fill('[name=password2]', 'password123'); | |
| 30 | + | await regPage.click('button[type=submit]'); | |
| 31 | + | await regPage.waitForURL(BASE + '/'); | |
| 32 | + | } finally { await regCtx.close(); } | |
| 33 | + | ||
| 34 | + | // Create my-repo | |
| 35 | + | const adminCtx = await browser.newContext(); | |
| 36 | + | const adminPage = await adminCtx.newPage(); | |
| 37 | + | try { | |
| 38 | + | await login(adminPage); | |
| 39 | + | await adminPage.goto(`${BASE}/new`); | |
| 40 | + | await adminPage.fill('[name=name]', 'my-repo'); | |
| 41 | + | await adminPage.click('form[action="/new"] button[type=submit]'); | |
| 42 | + | await adminPage.waitForURL(`${BASE}/my-repo`); | |
| 43 | + | } finally { await adminCtx.close(); } | |
| 44 | + | ||
| 45 | + | await seedRepo('my-repo'); | |
| 46 | + | }); | |
| 47 | + | ||
| 48 | + | afterAll(async () => { | |
| 49 | + | await browser.close(); | |
| 50 | + | await killServer(server); | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 54 | + | const ctx = await browser.newContext(); | |
| 55 | + | const page = await ctx.newPage(); | |
| 56 | + | await login(page, username, password); | |
| 57 | + | await page.close(); | |
| 58 | + | return ctx; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // ─── Issues ─────────────────────────────────────────────────────────────────── | |
| 62 | + | ||
| 63 | + | describe('issues', () => { | |
| 64 | + | let adminCtx: BrowserContext; | |
| 65 | + | let issueUrl: string; | |
| 66 | + | let completedIssueUrl: string; | |
| 67 | + | ||
| 68 | + | beforeAll(async () => { | |
| 69 | + | adminCtx = await loggedInContext(); | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 73 | + | ||
| 74 | + | test('create issue', async () => { | |
| 75 | + | const page = await adminCtx.newPage(); | |
| 76 | + | try { | |
| 77 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 78 | + | await page.fill('[name=title]', 'First issue'); | |
| 79 | + | await page.fill('[name=body]', 'Body with **markdown**.'); | |
| 80 | + | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 81 | + | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 82 | + | issueUrl = page.url(); | |
| 83 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue'); | |
| 84 | + | } finally { await page.close(); } | |
| 85 | + | }); | |
| 86 | + | ||
| 87 | + | test('issue body renders markdown', async () => { | |
| 88 | + | const page = await adminCtx.newPage(); | |
| 89 | + | try { | |
| 90 | + | await page.goto(issueUrl); | |
| 91 | + | expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>'); | |
| 92 | + | } finally { await page.close(); } | |
| 93 | + | }); | |
| 94 | + | ||
| 95 | + | test('issue appears in open list', async () => { | |
| 96 | + | const page = await adminCtx.newPage(); | |
| 97 | + | try { | |
| 98 | + | await page.goto(`${BASE}/my-repo/issues`); | |
| 99 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 100 | + | expect(titles.some(t => t.includes('First issue'))).toBe(true); | |
| 101 | + | } finally { await page.close(); } | |
| 102 | + | }); | |
| 103 | + | ||
| 104 | + | test('unauthenticated user is redirected to login from new issue form', async () => { | |
| 105 | + | const ctx = await browser.newContext(); | |
| 106 | + | const page = await ctx.newPage(); | |
| 107 | + | try { | |
| 108 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 109 | + | expect(page.url()).toContain('/login'); | |
| 110 | + | } finally { await ctx.close(); } | |
| 111 | + | }); | |
| 112 | + | ||
| 113 | + | test('add comment', async () => { | |
| 114 | + | const page = await adminCtx.newPage(); | |
| 115 | + | try { | |
| 116 | + | await page.goto(issueUrl); | |
| 117 | + | const beforeCount = await page.locator('.timeline-item').count(); | |
| 118 | + | await page.fill('textarea[name=body]', 'A follow-up comment.'); | |
| 119 | + | await page.click('form[action*="/comments"] button[type=submit]'); | |
| 120 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 121 | + | expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount); | |
| 122 | + | } finally { await page.close(); } | |
| 123 | + | }); | |
| 124 | + | ||
| 125 | + | test('react to issue', async () => { | |
| 126 | + | const page = await adminCtx.newPage(); | |
| 127 | + | try { | |
| 128 | + | await page.goto(issueUrl); | |
| 129 | + | await page.locator('.reaction-picker').first().click(); | |
| 130 | + | await page.locator('.reaction-picker-btn').first().click(); | |
| 131 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 132 | + | expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0); | |
| 133 | + | } finally { await page.close(); } | |
| 134 | + | }); | |
| 135 | + | ||
| 136 | + | test('close issue changes status badge', async () => { | |
| 137 | + | const page = await adminCtx.newPage(); | |
| 138 | + | try { | |
| 139 | + | await page.goto(issueUrl); | |
| 140 | + | await page.click('form[action*="/close"] button'); | |
| 141 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 142 | + | expect(await page.locator('.issue-badge').textContent()).toBe('closed'); | |
| 143 | + | } finally { await page.close(); } | |
| 144 | + | }); | |
| 145 | + | ||
| 146 | + | test('closed issue appears in closed list', async () => { | |
| 147 | + | const page = await adminCtx.newPage(); | |
| 148 | + | try { | |
| 149 | + | await page.goto(`${BASE}/my-repo/issues?status=closed`); | |
| 150 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 151 | + | expect(titles.some(t => t.includes('First issue'))).toBe(true); | |
| 152 | + | } finally { await page.close(); } | |
| 153 | + | }); | |
| 154 | + | ||
| 155 | + | test('reopen issue', async () => { | |
| 156 | + | const page = await adminCtx.newPage(); | |
| 157 | + | try { | |
| 158 | + | await page.goto(issueUrl); | |
| 159 | + | await page.click('.issue-detail-meta-actions button'); | |
| 160 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 161 | + | expect(await page.locator('.issue-badge').textContent()).toBe('open'); | |
| 162 | + | } finally { await page.close(); } | |
| 163 | + | }); | |
| 164 | + | ||
| 165 | + | test('completed button marks issue as completed', async () => { | |
| 166 | + | const page = await adminCtx.newPage(); | |
| 167 | + | try { | |
| 168 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 169 | + | await page.fill('[name=title]', 'To be completed'); | |
| 170 | + | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 171 | + | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 172 | + | completedIssueUrl = page.url(); | |
| 173 | + | await page.click('form[action*="/complete"] button'); | |
| 174 | + | await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, ''))); | |
| 175 | + | expect(await page.locator('.issue-badge').textContent()).toBe('completed'); | |
| 176 | + | } finally { await page.close(); } | |
| 177 | + | }); | |
| 178 | + | ||
| 179 | + | test('completed issue appears in completed list', async () => { | |
| 180 | + | const page = await adminCtx.newPage(); | |
| 181 | + | try { | |
| 182 | + | await page.goto(`${BASE}/my-repo/issues?status=completed`); | |
| 183 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 184 | + | expect(titles.some(t => t.includes('To be completed'))).toBe(true); | |
| 185 | + | } finally { await page.close(); } | |
| 186 | + | }); | |
| 187 | + | ||
| 188 | + | test('non-admin cannot complete or close issue', async () => { | |
| 189 | + | const issueNum = issueUrl.split('/issues/')[1]; | |
| 190 | + | const ctx = await browser.newContext(); | |
| 191 | + | try { | |
| 192 | + | const completeResp = await ctx.request.post( | |
| 193 | + | `${BASE}/my-repo/issues/${issueNum}/complete`, | |
| 194 | + | { maxRedirects: 0 }, | |
| 195 | + | ); | |
| 196 | + | expect(completeResp.status()).toBe(302); | |
| 197 | + | expect(completeResp.headers()['location']).toContain('/login'); | |
| 198 | + | } finally { await ctx.close(); } | |
| 199 | + | }); | |
| 200 | + | ||
| 201 | + | test('reacting with same emoji toggles it off', async () => { | |
| 202 | + | const page = await adminCtx.newPage(); | |
| 203 | + | try { | |
| 204 | + | await page.goto(issueUrl); | |
| 205 | + | // Reaction was added by the earlier 'react to issue' test | |
| 206 | + | expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0); | |
| 207 | + | await page.locator('.reaction-btn').first().click(); | |
| 208 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 209 | + | expect(await page.locator('.reaction-btn').count()).toBe(0); | |
| 210 | + | } finally { await page.close(); } | |
| 211 | + | }); | |
| 212 | + | ||
| 213 | + | test('react to issue comment', async () => { | |
| 214 | + | const page = await adminCtx.newPage(); | |
| 215 | + | try { | |
| 216 | + | await page.goto(issueUrl); | |
| 217 | + | const commentItem = page.locator('.timeline-item:not(.timeline-item-new)') | |
| 218 | + | .filter({ hasText: 'A follow-up comment.' }); | |
| 219 | + | await commentItem.locator('.reaction-add-btn').click(); | |
| 220 | + | await commentItem.locator('.reaction-picker-btn').first().click(); | |
| 221 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 222 | + | expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0); | |
| 223 | + | } finally { await page.close(); } | |
| 224 | + | }); | |
| 225 | + | }); | |
| 226 | + | ||
| 227 | + | // ─── Issue editing and deletion ─────────────────────────────────────────────── | |
| 228 | + | ||
| 229 | + | describe('issue editing', () => { | |
| 230 | + | let adminCtx: BrowserContext; | |
| 231 | + | let aliceCtx: BrowserContext; | |
| 232 | + | let issueUrl: string; | |
| 233 | + | ||
| 234 | + | beforeAll(async () => { | |
| 235 | + | adminCtx = await loggedInContext(); | |
| 236 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 237 | + | ||
| 238 | + | // Create an issue to edit | |
| 239 | + | const page = await adminCtx.newPage(); | |
| 240 | + | try { | |
| 241 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 242 | + | await page.fill('[name=title]', 'Issue to edit'); | |
| 243 | + | await page.fill('[name=body]', 'Original body.'); | |
| 244 | + | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 245 | + | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 246 | + | issueUrl = page.url(); | |
| 247 | + | } finally { await page.close(); } | |
| 248 | + | }); | |
| 249 | + | ||
| 250 | + | afterAll(async () => { | |
| 251 | + | await adminCtx.close(); | |
| 252 | + | await aliceCtx.close(); | |
| 253 | + | }); | |
| 254 | + | ||
| 255 | + | test('author can edit issue title and body', async () => { | |
| 256 | + | const page = await adminCtx.newPage(); | |
| 257 | + | try { | |
| 258 | + | await page.goto(issueUrl); | |
| 259 | + | // Edit title via title form | |
| 260 | + | await page.click('details.title-edit-details summary'); | |
| 261 | + | await page.fill('.title-edit-form-area [name=title]', 'Edited issue title'); | |
| 262 | + | await page.click('.title-edit-form-area [type=submit]'); | |
| 263 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 264 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title'); | |
| 265 | + | // Edit body via inline form | |
| 266 | + | await page.click('.timeline-author .inline-edit-details summary'); | |
| 267 | + | await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.'); | |
| 268 | + | await page.click('.inline-edit-form-area [type=submit]'); | |
| 269 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 270 | + | } finally { await page.close(); } | |
| 271 | + | }); | |
| 272 | + | ||
| 273 | + | test('edited marker appears after editing', async () => { | |
| 274 | + | const page = await adminCtx.newPage(); | |
| 275 | + | try { | |
| 276 | + | await page.goto(issueUrl); | |
| 277 | + | expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0); | |
| 278 | + | } finally { await page.close(); } | |
| 279 | + | }); | |
| 280 | + | ||
| 281 | + | test('non-author non-admin cannot edit issue', async () => { | |
| 282 | + | const page = await aliceCtx.newPage(); | |
| 283 | + | try { | |
| 284 | + | const issueNum = issueUrl.split('/issues/')[1]; | |
| 285 | + | const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, { | |
| 286 | + | form: { title: 'Hacked title', edit_body: '' }, | |
| 287 | + | maxRedirects: 0, | |
| 288 | + | }); | |
| 289 | + | expect(resp.status()).toBe(403); | |
| 290 | + | } finally { await page.close(); } | |
| 291 | + | }); | |
| 292 | + | ||
| 293 | + | test('author can edit issue comment', async () => { | |
| 294 | + | const page = await adminCtx.newPage(); | |
| 295 | + | try { | |
| 296 | + | await page.goto(issueUrl); | |
| 297 | + | // Add a comment first | |
| 298 | + | await page.fill('textarea[name=body]', 'Comment to edit.'); | |
| 299 | + | await page.click('form[action*="/comments"] button[type=submit]'); | |
| 300 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 301 | + | ||
| 302 | + | // Edit the comment | |
| 303 | + | const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' }); | |
| 304 | + | await commentItem.locator('.inline-edit-details summary').click(); | |
| 305 | + | await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.'); | |
| 306 | + | await commentItem.locator('.inline-edit-form-area [type=submit]').click(); | |
| 307 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 308 | + | expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.'); | |
| 309 | + | } finally { await page.close(); } | |
| 310 | + | }); | |
| 311 | + | ||
| 312 | + | test('non-admin user can create an issue', async () => { | |
| 313 | + | const page = await aliceCtx.newPage(); | |
| 314 | + | try { | |
| 315 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 316 | + | await page.fill('[name=title]', "Alice's issue"); | |
| 317 | + | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 318 | + | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 319 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue"); | |
| 320 | + | } finally { await page.close(); } | |
| 321 | + | }); | |
| 322 | + | ||
| 323 | + | test('non-admin cannot comment on a closed issue', async () => { | |
| 324 | + | // Close the issue as admin first | |
| 325 | + | const issueNum = issueUrl.split('/issues/')[1]; | |
| 326 | + | await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {}); | |
| 327 | + | ||
| 328 | + | const page = await aliceCtx.newPage(); | |
| 329 | + | try { | |
| 330 | + | const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, { | |
| 331 | + | form: { body: 'comment on closed issue' }, | |
| 332 | + | maxRedirects: 0, | |
| 333 | + | }); | |
| 334 | + | // Non-admin gets redirected (silently ignored), not an error | |
| 335 | + | expect(resp.status()).toBe(302); | |
| 336 | + | // The comment should NOT appear | |
| 337 | + | await page.goto(issueUrl); | |
| 338 | + | const bodies = await page.locator('.timeline-body').allTextContents(); | |
| 339 | + | expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true); | |
| 340 | + | } finally { await page.close(); } | |
| 341 | + | }); | |
| 342 | + | ||
| 343 | + | test('admin can delete issue', async () => { | |
| 344 | + | const issueNum = issueUrl.split('/issues/')[1]; | |
| 345 | + | const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, { | |
| 346 | + | maxRedirects: 0, | |
| 347 | + | }); | |
| 348 | + | expect(resp.status()).toBe(302); | |
| 349 | + | // Issue should be gone | |
| 350 | + | const page = await adminCtx.newPage(); | |
| 351 | + | try { | |
| 352 | + | const checkResp = await page.request.get(issueUrl); | |
| 353 | + | expect(checkResp.status()).toBe(404); | |
| 354 | + | } finally { await page.close(); } | |
| 355 | + | }); | |
| 356 | + | }); | |
| 357 | + | ||
| 358 | + | // ─── Repository description update ─────────────────────────────────────────── | |
| 359 | + | ||
| 360 | + | describe('repo description', () => { | |
| 361 | + | let adminCtx: BrowserContext; | |
| 362 | + | ||
| 363 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 364 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 365 | + | ||
| 366 | + | test('updating repo description is reflected on list page', async () => { | |
| 367 | + | const page = await adminCtx.newPage(); | |
| 368 | + | try { | |
| 369 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 370 | + | await page.fill('[name=description]', 'A freshly updated description'); | |
| 371 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 372 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 373 | + | ||
| 374 | + | await page.goto(BASE); | |
| 375 | + | const desc = await page.locator('.repo-description').allTextContents(); | |
| 376 | + | expect(desc.some(d => d.includes('freshly updated description'))).toBe(true); | |
| 377 | + | } finally { await page.close(); } | |
| 378 | + | }); | |
| 379 | + | }); | |
| 380 | + | ||
| 381 | + | // ─── Issue and patch templates ──────────────────────────────────────────────── | |
| 382 | + | ||
| 383 | + | describe('issue and patch templates', () => { | |
| 384 | + | let adminCtx: BrowserContext; | |
| 385 | + | ||
| 386 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 387 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 388 | + | ||
| 389 | + | test('issue template can be saved and is prefilled on new issue form', async () => { | |
| 390 | + | const page = await adminCtx.newPage(); | |
| 391 | + | try { | |
| 392 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 393 | + | await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior'); | |
| 394 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 395 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 396 | + | ||
| 397 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 398 | + | const body = await page.locator('[name=body]').inputValue(); | |
| 399 | + | expect(body).toContain('## Steps to reproduce'); | |
| 400 | + | expect(body).toContain('## Expected behavior'); | |
| 401 | + | } finally { await page.close(); } | |
| 402 | + | }); | |
| 403 | + | ||
| 404 | + | test('patch template can be saved and is prefilled on new patch form', async () => { | |
| 405 | + | const page = await adminCtx.newPage(); | |
| 406 | + | try { | |
| 407 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 408 | + | await page.fill('[name=patch_template]', '## Summary\n\n## Testing'); | |
| 409 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 410 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 411 | + | ||
| 412 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 413 | + | const desc = await page.locator('[name=description]').inputValue(); | |
| 414 | + | expect(desc).toContain('## Summary'); | |
| 415 | + | expect(desc).toContain('## Testing'); | |
| 416 | + | } finally { await page.close(); } | |
| 417 | + | }); | |
| 418 | + | ||
| 419 | + | test('clearing the issue template removes prefill', async () => { | |
| 420 | + | const page = await adminCtx.newPage(); | |
| 421 | + | try { | |
| 422 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 423 | + | await page.fill('[name=issue_template]', ''); | |
| 424 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 425 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 426 | + | ||
| 427 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 428 | + | const body = await page.locator('[name=body]').inputValue(); | |
| 429 | + | expect(body).toBe(''); | |
| 430 | + | } finally { await page.close(); } | |
| 431 | + | }); | |
| 432 | + | ||
| 433 | + | test('clearing the patch template removes prefill', async () => { | |
| 434 | + | const page = await adminCtx.newPage(); | |
| 435 | + | try { | |
| 436 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 437 | + | await page.fill('[name=patch_template]', ''); | |
| 438 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 439 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 440 | + | ||
| 441 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 442 | + | const desc = await page.locator('[name=description]').inputValue(); | |
| 443 | + | expect(desc).toBe(''); | |
| 444 | + | } finally { await page.close(); } | |
| 445 | + | }); | |
| 446 | + | }); | |
Atests/e2e.labels.test.ts
| @@ -0,0 +1,614 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { | |
| 5 | + | BASE, | |
| 6 | + | ADMIN_PASS, | |
| 7 | + | setupTestEnv, | |
| 8 | + | spawnServer, | |
| 9 | + | killServer, | |
| 10 | + | login, | |
| 11 | + | writeTempFile, | |
| 12 | + | } from './helpers.ts'; | |
| 13 | + | ||
| 14 | + | let browser: Browser; | |
| 15 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 16 | + | ||
| 17 | + | beforeAll(async () => { | |
| 18 | + | await setupTestEnv(); | |
| 19 | + | server = await spawnServer(); | |
| 20 | + | browser = await chromium.launch(); | |
| 21 | + | ||
| 22 | + | // Register alice (needed for user label management) | |
| 23 | + | const regCtx = await browser.newContext(); | |
| 24 | + | const regPage = await regCtx.newPage(); | |
| 25 | + | try { | |
| 26 | + | await regPage.goto(`${BASE}/register`); | |
| 27 | + | await regPage.fill('[name=username]', 'alice'); | |
| 28 | + | await regPage.fill('[name=password]', 'password123'); | |
| 29 | + | await regPage.fill('[name=password2]', 'password123'); | |
| 30 | + | await regPage.click('button[type=submit]'); | |
| 31 | + | await regPage.waitForURL(BASE + '/'); | |
| 32 | + | } finally { await regCtx.close(); } | |
| 33 | + | }); | |
| 34 | + | ||
| 35 | + | afterAll(async () => { | |
| 36 | + | await browser.close(); | |
| 37 | + | await killServer(server); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 41 | + | const ctx = await browser.newContext(); | |
| 42 | + | const page = await ctx.newPage(); | |
| 43 | + | await login(page, username, password); | |
| 44 | + | await page.close(); | |
| 45 | + | return ctx; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ─── Labels ─────────────────────────────────────────────────────────────────── | |
| 49 | + | ||
| 50 | + | describe('labels', () => { | |
| 51 | + | let adminCtx: BrowserContext; | |
| 52 | + | let issueUrl: string; | |
| 53 | + | let patchUrl: string; | |
| 54 | + | ||
| 55 | + | const VALID_PATCH = [ | |
| 56 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 57 | + | 'From: Test User <test@example.com>', | |
| 58 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 59 | + | 'Subject: [PATCH] Add label-test.txt', | |
| 60 | + | '', | |
| 61 | + | '---', | |
| 62 | + | 'diff --git a/label-test.txt b/label-test.txt', | |
| 63 | + | 'new file mode 100644', | |
| 64 | + | 'index 0000000..9daeafb', | |
| 65 | + | '--- /dev/null', | |
| 66 | + | '+++ b/label-test.txt', | |
| 67 | + | '@@ -0,0 +1 @@', | |
| 68 | + | '+x', | |
| 69 | + | '', | |
| 70 | + | ].join('\n'); | |
| 71 | + | ||
| 72 | + | beforeAll(async () => { | |
| 73 | + | adminCtx = await loggedInContext(); | |
| 74 | + | ||
| 75 | + | // Create a dedicated repo for label tests | |
| 76 | + | const page = await adminCtx.newPage(); | |
| 77 | + | try { | |
| 78 | + | await page.goto(`${BASE}/new`); | |
| 79 | + | await page.fill('[name=name]', 'label-repo'); | |
| 80 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 81 | + | await page.waitForURL(`${BASE}/label-repo`); | |
| 82 | + | } finally { await page.close(); } | |
| 83 | + | ||
| 84 | + | // Create an issue | |
| 85 | + | const issuePage = await adminCtx.newPage(); | |
| 86 | + | try { | |
| 87 | + | await issuePage.goto(`${BASE}/label-repo/issues/new`); | |
| 88 | + | await issuePage.fill('[name=title]', 'Labelled issue'); | |
| 89 | + | await issuePage.click('form[action$="/issues"] button[type=submit]'); | |
| 90 | + | await issuePage.waitForURL(/\/label-repo\/issues\/\d+/); | |
| 91 | + | issueUrl = issuePage.url(); | |
| 92 | + | } finally { await issuePage.close(); } | |
| 93 | + | ||
| 94 | + | // Create a patch | |
| 95 | + | writeTempFile('/tmp/label-test.patch', VALID_PATCH); | |
| 96 | + | const patchPage = await adminCtx.newPage(); | |
| 97 | + | try { | |
| 98 | + | await patchPage.goto(`${BASE}/label-repo/patches/new`); | |
| 99 | + | await patchPage.fill('[name=title]', 'Labelled patch'); | |
| 100 | + | await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch'); | |
| 101 | + | await patchPage.click('form[action$="/patches"] button[type=submit]'); | |
| 102 | + | await patchPage.waitForURL(/\/label-repo\/patches\/\d+/); | |
| 103 | + | patchUrl = patchPage.url(); | |
| 104 | + | } finally { await patchPage.close(); } | |
| 105 | + | }); | |
| 106 | + | ||
| 107 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 108 | + | ||
| 109 | + | test('create label in repo settings', async () => { | |
| 110 | + | const page = await adminCtx.newPage(); | |
| 111 | + | try { | |
| 112 | + | await page.goto(`${BASE}/label-repo/settings`); | |
| 113 | + | await page.fill('input[name=name]', 'bug'); | |
| 114 | + | await page.locator('input[type=color][name=color]').evaluate( | |
| 115 | + | (el: any) => { el.value = '#ff0000'; }, | |
| 116 | + | ); | |
| 117 | + | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 118 | + | await page.waitForURL(/\/label-repo\/settings/); | |
| 119 | + | expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug'); | |
| 120 | + | } finally { await page.close(); } | |
| 121 | + | }); | |
| 122 | + | ||
| 123 | + | test('create a second label', async () => { | |
| 124 | + | const page = await adminCtx.newPage(); | |
| 125 | + | try { | |
| 126 | + | await page.goto(`${BASE}/label-repo/settings`); | |
| 127 | + | await page.fill('input[name=name]', 'enhancement'); | |
| 128 | + | await page.locator('input[type=color][name=color]').evaluate( | |
| 129 | + | (el: any) => { el.value = '#00aa00'; }, | |
| 130 | + | ); | |
| 131 | + | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 132 | + | await page.waitForURL(/\/label-repo\/settings/); | |
| 133 | + | const badges = await page.locator('.label-settings-name').allTextContents(); | |
| 134 | + | expect(badges).toContain('bug'); | |
| 135 | + | expect(badges).toContain('enhancement'); | |
| 136 | + | } finally { await page.close(); } | |
| 137 | + | }); | |
| 138 | + | ||
| 139 | + | test('duplicate label name is rejected', async () => { | |
| 140 | + | const page = await adminCtx.newPage(); | |
| 141 | + | try { | |
| 142 | + | await page.goto(`${BASE}/label-repo/settings`); | |
| 143 | + | await page.fill('input[name=name]', 'bug'); | |
| 144 | + | await page.locator('input[type=color][name=color]').evaluate( | |
| 145 | + | (el: any) => { el.value = '#0000ff'; }, | |
| 146 | + | ); | |
| 147 | + | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 148 | + | await page.waitForURL(/\/label-repo\/settings/); | |
| 149 | + | expect(await page.locator('.form-error').isVisible()).toBe(true); | |
| 150 | + | } finally { await page.close(); } | |
| 151 | + | }); | |
| 152 | + | ||
| 153 | + | test('non-admin cannot create labels', async () => { | |
| 154 | + | const ctx = await browser.newContext(); | |
| 155 | + | try { | |
| 156 | + | const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, { | |
| 157 | + | form: { name: 'nope', color: '#123456' }, | |
| 158 | + | maxRedirects: 0, | |
| 159 | + | }); | |
| 160 | + | // Unauthenticated → redirected to /login | |
| 161 | + | expect(r.status()).toBe(302); | |
| 162 | + | expect(r.headers()['location']).toContain('/login'); | |
| 163 | + | } finally { await ctx.close(); } | |
| 164 | + | }); | |
| 165 | + | ||
| 166 | + | test('assign label to issue', async () => { | |
| 167 | + | const page = await adminCtx.newPage(); | |
| 168 | + | try { | |
| 169 | + | await page.goto(issueUrl); | |
| 170 | + | await page.selectOption('select[name=label_id]', { label: 'bug' }); | |
| 171 | + | await page.click('form[action$="/labels/add"] button[type=submit]'); | |
| 172 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 173 | + | expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); | |
| 174 | + | } finally { await page.close(); } | |
| 175 | + | }); | |
| 176 | + | ||
| 177 | + | test('label appears on issue list', async () => { | |
| 178 | + | const page = await adminCtx.newPage(); | |
| 179 | + | try { | |
| 180 | + | await page.goto(`${BASE}/label-repo/issues`); | |
| 181 | + | const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' }); | |
| 182 | + | expect(await item.locator('.label-badge').allTextContents()).toContain('bug'); | |
| 183 | + | } finally { await page.close(); } | |
| 184 | + | }); | |
| 185 | + | ||
| 186 | + | test('filter issues by label shows only matching issues', async () => { | |
| 187 | + | // Create a second issue without the label | |
| 188 | + | const createPage = await adminCtx.newPage(); | |
| 189 | + | try { | |
| 190 | + | await createPage.goto(`${BASE}/label-repo/issues/new`); | |
| 191 | + | await createPage.fill('[name=title]', 'Unlabelled issue'); | |
| 192 | + | await createPage.click('form[action$="/issues"] button[type=submit]'); | |
| 193 | + | await createPage.waitForURL(/\/label-repo\/issues\/\d+/); | |
| 194 | + | } finally { await createPage.close(); } | |
| 195 | + | ||
| 196 | + | // Open filter popup and apply label filter | |
| 197 | + | const page = await adminCtx.newPage(); | |
| 198 | + | try { | |
| 199 | + | await page.goto(`${BASE}/label-repo/issues`); | |
| 200 | + | // Get the label id from the checkbox | |
| 201 | + | const checkbox = page.locator('.label-filter-item input[name=labels]').first(); | |
| 202 | + | const labelId = await checkbox.getAttribute('value'); | |
| 203 | + | expect(labelId).toBeTruthy(); | |
| 204 | + | ||
| 205 | + | // Navigate with the filter applied via URL | |
| 206 | + | await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`); | |
| 207 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 208 | + | expect(titles.some(t => t.includes('Labelled issue'))).toBe(true); | |
| 209 | + | expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false); | |
| 210 | + | } finally { await page.close(); } | |
| 211 | + | }); | |
| 212 | + | ||
| 213 | + | test('filter popup is visible without JS', async () => { | |
| 214 | + | // details/summary is a native HTML element — verify it renders | |
| 215 | + | const page = await adminCtx.newPage(); | |
| 216 | + | try { | |
| 217 | + | await page.goto(`${BASE}/label-repo/issues`); | |
| 218 | + | expect(await page.locator('details.label-filter').isVisible()).toBe(true); | |
| 219 | + | expect(await page.locator('details.label-filter summary').isVisible()).toBe(true); | |
| 220 | + | } finally { await page.close(); } | |
| 221 | + | }); | |
| 222 | + | ||
| 223 | + | test('remove label from issue', async () => { | |
| 224 | + | const page = await adminCtx.newPage(); | |
| 225 | + | try { | |
| 226 | + | await page.goto(issueUrl); | |
| 227 | + | await page.click('form[action$="/labels/remove"] button[type=submit]'); | |
| 228 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 229 | + | // Label badge should no longer appear in the labels row | |
| 230 | + | const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); | |
| 231 | + | expect(labelBadges).not.toContain('bug'); | |
| 232 | + | } finally { await page.close(); } | |
| 233 | + | }); | |
| 234 | + | ||
| 235 | + | test('assign label to patch', async () => { | |
| 236 | + | const page = await adminCtx.newPage(); | |
| 237 | + | try { | |
| 238 | + | await page.goto(patchUrl); | |
| 239 | + | await page.selectOption('select[name=label_id]', { label: 'enhancement' }); | |
| 240 | + | await page.click('form[action$="/labels/add"] button[type=submit]'); | |
| 241 | + | await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); | |
| 242 | + | expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement'); | |
| 243 | + | } finally { await page.close(); } | |
| 244 | + | }); | |
| 245 | + | ||
| 246 | + | test('label appears on patch list', async () => { | |
| 247 | + | const page = await adminCtx.newPage(); | |
| 248 | + | try { | |
| 249 | + | await page.goto(`${BASE}/label-repo/patches`); | |
| 250 | + | const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' }); | |
| 251 | + | expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement'); | |
| 252 | + | } finally { await page.close(); } | |
| 253 | + | }); | |
| 254 | + | ||
| 255 | + | test('filter patches by label', async () => { | |
| 256 | + | const page = await adminCtx.newPage(); | |
| 257 | + | try { | |
| 258 | + | await page.goto(`${BASE}/label-repo/patches`); | |
| 259 | + | // Find the checkbox for the 'enhancement' label specifically | |
| 260 | + | const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' }) | |
| 261 | + | .locator('input[name=labels]'); | |
| 262 | + | const labelId = await checkbox.getAttribute('value'); | |
| 263 | + | expect(labelId).toBeTruthy(); | |
| 264 | + | ||
| 265 | + | await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`); | |
| 266 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 267 | + | expect(titles.some(t => t.includes('Labelled patch'))).toBe(true); | |
| 268 | + | } finally { await page.close(); } | |
| 269 | + | }); | |
| 270 | + | ||
| 271 | + | test('remove label from patch', async () => { | |
| 272 | + | const page = await adminCtx.newPage(); | |
| 273 | + | try { | |
| 274 | + | await page.goto(patchUrl); | |
| 275 | + | await page.click('form[action$="/labels/remove"] button[type=submit]'); | |
| 276 | + | await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); | |
| 277 | + | const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); | |
| 278 | + | expect(labelBadges).not.toContain('enhancement'); | |
| 279 | + | } finally { await page.close(); } | |
| 280 | + | }); | |
| 281 | + | ||
| 282 | + | test('delete label removes it from settings list', async () => { | |
| 283 | + | const page = await adminCtx.newPage(); | |
| 284 | + | try { | |
| 285 | + | await page.goto(`${BASE}/label-repo/settings`); | |
| 286 | + | const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' }); | |
| 287 | + | await bugItem.locator('form[action$="/labels/delete"] button').click(); | |
| 288 | + | await page.waitForURL(/\/label-repo\/settings/); | |
| 289 | + | const badges = await page.locator('.label-settings-name').allTextContents(); | |
| 290 | + | expect(badges).not.toContain('bug'); | |
| 291 | + | } finally { await page.close(); } | |
| 292 | + | }); | |
| 293 | + | ||
| 294 | + | test('deleted label no longer appears in filter popup', async () => { | |
| 295 | + | const page = await adminCtx.newPage(); | |
| 296 | + | try { | |
| 297 | + | await page.goto(`${BASE}/label-repo/issues`); | |
| 298 | + | const filterLabels = await page.locator('.label-filter-item').allTextContents(); | |
| 299 | + | expect(filterLabels.every(t => !t.includes('bug'))).toBe(true); | |
| 300 | + | } finally { await page.close(); } | |
| 301 | + | }); | |
| 302 | + | }); | |
| 303 | + | ||
| 304 | + | // ─── User label management ──────────────────────────────────────────────────── | |
| 305 | + | ||
| 306 | + | describe('user label management', () => { | |
| 307 | + | let adminCtx: BrowserContext; | |
| 308 | + | let aliceCtx: BrowserContext; | |
| 309 | + | // URL of an issue owned by alice | |
| 310 | + | let aliceIssueUrl: string; | |
| 311 | + | // URL of an issue owned by admin | |
| 312 | + | let adminIssueUrl: string; | |
| 313 | + | // label IDs, fetched from the filter inputs | |
| 314 | + | let bugLabelId: string; | |
| 315 | + | ||
| 316 | + | const VALID_PATCH = [ | |
| 317 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 318 | + | 'From: Test User <test@example.com>', | |
| 319 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 320 | + | 'Subject: [PATCH] Add ulm-test.txt', | |
| 321 | + | '', | |
| 322 | + | '---', | |
| 323 | + | 'diff --git a/ulm-test.txt b/ulm-test.txt', | |
| 324 | + | 'new file mode 100644', | |
| 325 | + | 'index 0000000..9daeafb', | |
| 326 | + | '--- /dev/null', | |
| 327 | + | '+++ b/ulm-test.txt', | |
| 328 | + | '@@ -0,0 +1 @@', | |
| 329 | + | '+x', | |
| 330 | + | '', | |
| 331 | + | ].join('\n'); | |
| 332 | + | ||
| 333 | + | beforeAll(async () => { | |
| 334 | + | adminCtx = await loggedInContext(); | |
| 335 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 336 | + | ||
| 337 | + | // Create dedicated repo | |
| 338 | + | const repoPage = await adminCtx.newPage(); | |
| 339 | + | try { | |
| 340 | + | await repoPage.goto(`${BASE}/new`); | |
| 341 | + | await repoPage.fill('[name=name]', 'ulm-repo'); | |
| 342 | + | await repoPage.click('form[action="/new"] button[type=submit]'); | |
| 343 | + | await repoPage.waitForURL(`${BASE}/ulm-repo`); | |
| 344 | + | } finally { await repoPage.close(); } | |
| 345 | + | ||
| 346 | + | // Create labels 'bug' and 'feature' | |
| 347 | + | for (const name of ['bug', 'feature']) { | |
| 348 | + | const p = await adminCtx.newPage(); | |
| 349 | + | try { | |
| 350 | + | await p.goto(`${BASE}/ulm-repo/settings`); | |
| 351 | + | await p.fill('input[name=name]', name); | |
| 352 | + | await p.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 353 | + | await p.waitForURL(/\/ulm-repo\/settings/); | |
| 354 | + | } finally { await p.close(); } | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | // Create an issue owned by admin | |
| 358 | + | const adminIssuePage = await adminCtx.newPage(); | |
| 359 | + | try { | |
| 360 | + | await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`); | |
| 361 | + | await adminIssuePage.fill('[name=title]', "Admin's issue"); | |
| 362 | + | await adminIssuePage.click('form[action$="/issues"] button[type=submit]'); | |
| 363 | + | await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/); | |
| 364 | + | adminIssueUrl = adminIssuePage.url(); | |
| 365 | + | } finally { await adminIssuePage.close(); } | |
| 366 | + | ||
| 367 | + | // Create an issue owned by alice | |
| 368 | + | const aliceIssuePage = await aliceCtx.newPage(); | |
| 369 | + | try { | |
| 370 | + | await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`); | |
| 371 | + | await aliceIssuePage.fill('[name=title]', "Alice's issue"); | |
| 372 | + | await aliceIssuePage.click('form[action$="/issues"] button[type=submit]'); | |
| 373 | + | await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/); | |
| 374 | + | aliceIssueUrl = aliceIssuePage.url(); | |
| 375 | + | } finally { await aliceIssuePage.close(); } | |
| 376 | + | ||
| 377 | + | // Grab the bug label id from the filter popup | |
| 378 | + | const filterPage = await adminCtx.newPage(); | |
| 379 | + | try { | |
| 380 | + | await filterPage.goto(`${BASE}/ulm-repo/issues`); | |
| 381 | + | const checkbox = filterPage.locator('.label-filter-item') | |
| 382 | + | .filter({ hasText: 'bug' }) | |
| 383 | + | .locator('input[name=labels]'); | |
| 384 | + | bugLabelId = (await checkbox.getAttribute('value')) ?? ''; | |
| 385 | + | } finally { await filterPage.close(); } | |
| 386 | + | }); | |
| 387 | + | ||
| 388 | + | afterAll(async () => { | |
| 389 | + | await adminCtx.close(); | |
| 390 | + | await aliceCtx.close(); | |
| 391 | + | }); | |
| 392 | + | ||
| 393 | + | // ── Settings ── | |
| 394 | + | ||
| 395 | + | test('allow_user_labels checkbox is present in repo settings', async () => { | |
| 396 | + | const page = await adminCtx.newPage(); | |
| 397 | + | try { | |
| 398 | + | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 399 | + | expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1); | |
| 400 | + | } finally { await page.close(); } | |
| 401 | + | }); | |
| 402 | + | ||
| 403 | + | test('allow_user_labels is off by default', async () => { | |
| 404 | + | const page = await adminCtx.newPage(); | |
| 405 | + | try { | |
| 406 | + | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 407 | + | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false); | |
| 408 | + | } finally { await page.close(); } | |
| 409 | + | }); | |
| 410 | + | ||
| 411 | + | // ── Label checkboxes hidden when setting is off ── | |
| 412 | + | ||
| 413 | + | test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => { | |
| 414 | + | const page = await aliceCtx.newPage(); | |
| 415 | + | try { | |
| 416 | + | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 417 | + | expect(await page.locator('.label-checkbox-list').count()).toBe(0); | |
| 418 | + | } finally { await page.close(); } | |
| 419 | + | }); | |
| 420 | + | ||
| 421 | + | test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => { | |
| 422 | + | const page = await aliceCtx.newPage(); | |
| 423 | + | try { | |
| 424 | + | await page.goto(`${BASE}/ulm-repo/patches/new`); | |
| 425 | + | expect(await page.locator('.label-checkbox-list').count()).toBe(0); | |
| 426 | + | } finally { await page.close(); } | |
| 427 | + | }); | |
| 428 | + | ||
| 429 | + | test('label checkboxes shown to admin on new issue form regardless of setting', async () => { | |
| 430 | + | const page = await adminCtx.newPage(); | |
| 431 | + | try { | |
| 432 | + | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 433 | + | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); | |
| 434 | + | } finally { await page.close(); } | |
| 435 | + | }); | |
| 436 | + | ||
| 437 | + | // ── Enable the setting ── | |
| 438 | + | ||
| 439 | + | test('admin can enable allow_user_labels', async () => { | |
| 440 | + | const page = await adminCtx.newPage(); | |
| 441 | + | try { | |
| 442 | + | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 443 | + | await page.check('input[name=allow_user_labels]'); | |
| 444 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 445 | + | await page.waitForURL(/\/ulm-repo\/settings/); | |
| 446 | + | // Verify it persisted | |
| 447 | + | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 448 | + | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true); | |
| 449 | + | } finally { await page.close(); } | |
| 450 | + | }); | |
| 451 | + | ||
| 452 | + | // ── Label checkboxes visible when setting is on ── | |
| 453 | + | ||
| 454 | + | test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => { | |
| 455 | + | const page = await aliceCtx.newPage(); | |
| 456 | + | try { | |
| 457 | + | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 458 | + | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); | |
| 459 | + | const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents(); | |
| 460 | + | expect(labels).toContain('bug'); | |
| 461 | + | expect(labels).toContain('feature'); | |
| 462 | + | } finally { await page.close(); } | |
| 463 | + | }); | |
| 464 | + | ||
| 465 | + | test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => { | |
| 466 | + | const page = await aliceCtx.newPage(); | |
| 467 | + | try { | |
| 468 | + | await page.goto(`${BASE}/ulm-repo/patches/new`); | |
| 469 | + | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); | |
| 470 | + | } finally { await page.close(); } | |
| 471 | + | }); | |
| 472 | + | ||
| 473 | + | // ── Creating with labels selected ── | |
| 474 | + | ||
| 475 | + | test('non-admin can create issue with label selected', async () => { | |
| 476 | + | const page = await aliceCtx.newPage(); | |
| 477 | + | try { | |
| 478 | + | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 479 | + | await page.fill('[name=title]', 'Issue with label'); | |
| 480 | + | // Check the 'bug' label checkbox | |
| 481 | + | await page.locator('.label-checkbox-item').filter({ hasText: 'bug' }) | |
| 482 | + | .locator('input[type=checkbox]').check(); | |
| 483 | + | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 484 | + | await page.waitForURL(/\/ulm-repo\/issues\/\d+/); | |
| 485 | + | const badges = await page.locator('.label-badge').allTextContents(); | |
| 486 | + | expect(badges).toContain('bug'); | |
| 487 | + | } finally { await page.close(); } | |
| 488 | + | }); | |
| 489 | + | ||
| 490 | + | test('non-admin can create patch with label selected', async () => { | |
| 491 | + | writeTempFile('/tmp/ulm-test.patch', VALID_PATCH); | |
| 492 | + | const page = await aliceCtx.newPage(); | |
| 493 | + | try { | |
| 494 | + | await page.goto(`${BASE}/ulm-repo/patches/new`); | |
| 495 | + | await page.fill('[name=title]', 'Patch with label'); | |
| 496 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch'); | |
| 497 | + | await page.locator('.label-checkbox-item').filter({ hasText: 'feature' }) | |
| 498 | + | .locator('input[type=checkbox]').check(); | |
| 499 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 500 | + | await page.waitForURL(/\/ulm-repo\/patches\/\d+/); | |
| 501 | + | const badges = await page.locator('.label-badge').allTextContents(); | |
| 502 | + | expect(badges).toContain('feature'); | |
| 503 | + | } finally { await page.close(); } | |
| 504 | + | }); | |
| 505 | + | ||
| 506 | + | test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => { | |
| 507 | + | // Temporarily disable the setting, post with label_ids, re-enable | |
| 508 | + | await adminCtx.request.post(`${BASE}/ulm-repo/settings`, { | |
| 509 | + | form: { description: '', default_branch: 'main' }, // no allow_user_labels | |
| 510 | + | maxRedirects: 0, | |
| 511 | + | }).catch(() => {}); | |
| 512 | + | ||
| 513 | + | const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, { | |
| 514 | + | form: { title: 'Issue sneaking labels', label_ids: bugLabelId }, | |
| 515 | + | maxRedirects: 0, | |
| 516 | + | }).catch(() => null); | |
| 517 | + | // Should redirect to the new issue | |
| 518 | + | const location = resp?.headers()['location'] ?? ''; | |
| 519 | + | const issueNum = location.split('/issues/')[1]; | |
| 520 | + | ||
| 521 | + | if (issueNum) { | |
| 522 | + | const page = await aliceCtx.newPage(); | |
| 523 | + | try { | |
| 524 | + | await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`); | |
| 525 | + | const badges = await page.locator('.label-badge').allTextContents(); | |
| 526 | + | expect(badges).not.toContain('bug'); | |
| 527 | + | } finally { await page.close(); } | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | // Re-enable for subsequent tests | |
| 531 | + | await adminCtx.request.post(`${BASE}/ulm-repo/settings`, { | |
| 532 | + | form: { description: '', default_branch: 'main', allow_user_labels: '1' }, | |
| 533 | + | maxRedirects: 0, | |
| 534 | + | }).catch(() => {}); | |
| 535 | + | }); | |
| 536 | + | ||
| 537 | + | // ── Add/remove labels on existing items ── | |
| 538 | + | ||
| 539 | + | test('non-admin can add label to their own issue', async () => { | |
| 540 | + | const issueNum = aliceIssueUrl.split('/issues/')[1]; | |
| 541 | + | const page = await aliceCtx.newPage(); | |
| 542 | + | try { | |
| 543 | + | await page.goto(aliceIssueUrl); | |
| 544 | + | await page.selectOption('select[name=label_id]', { label: 'bug' }); | |
| 545 | + | await page.click('form[action$="/labels/add"] button[type=submit]'); | |
| 546 | + | await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`)); | |
| 547 | + | expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); | |
| 548 | + | } finally { await page.close(); } | |
| 549 | + | }); | |
| 550 | + | ||
| 551 | + | test('non-admin can remove label from their own issue', async () => { | |
| 552 | + | const issueNum = aliceIssueUrl.split('/issues/')[1]; | |
| 553 | + | const page = await aliceCtx.newPage(); | |
| 554 | + | try { | |
| 555 | + | await page.goto(aliceIssueUrl); | |
| 556 | + | await page.click('form[action$="/labels/remove"] button[type=submit]'); | |
| 557 | + | await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`)); | |
| 558 | + | const badges = await page.locator('.issue-labels-row .label-badge').allTextContents(); | |
| 559 | + | expect(badges).not.toContain('bug'); | |
| 560 | + | } finally { await page.close(); } | |
| 561 | + | }); | |
| 562 | + | ||
| 563 | + | test('non-admin cannot add label to another user\'s issue', async () => { | |
| 564 | + | const issueNum = adminIssueUrl.split('/issues/')[1]; | |
| 565 | + | const resp = await aliceCtx.request.post( | |
| 566 | + | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, | |
| 567 | + | { form: { label_id: bugLabelId }, maxRedirects: 0 }, | |
| 568 | + | ); | |
| 569 | + | expect(resp.status()).toBe(403); | |
| 570 | + | }); | |
| 571 | + | ||
| 572 | + | test('unauthenticated user gets 401 adding a label', async () => { | |
| 573 | + | const issueNum = aliceIssueUrl.split('/issues/')[1]; | |
| 574 | + | const ctx = await browser.newContext(); | |
| 575 | + | try { | |
| 576 | + | const resp = await ctx.request.post( | |
| 577 | + | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, | |
| 578 | + | { form: { label_id: bugLabelId }, maxRedirects: 0 }, | |
| 579 | + | ); | |
| 580 | + | expect(resp.status()).toBe(401); | |
| 581 | + | } finally { await ctx.close(); } | |
| 582 | + | }); | |
| 583 | + | ||
| 584 | + | // ── Disable setting and verify enforcement ── | |
| 585 | + | ||
| 586 | + | test('admin can disable allow_user_labels', async () => { | |
| 587 | + | const page = await adminCtx.newPage(); | |
| 588 | + | try { | |
| 589 | + | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 590 | + | await page.uncheck('input[name=allow_user_labels]'); | |
| 591 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 592 | + | await page.waitForURL(/\/ulm-repo\/settings/); | |
| 593 | + | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 594 | + | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false); | |
| 595 | + | } finally { await page.close(); } | |
| 596 | + | }); | |
| 597 | + | ||
| 598 | + | test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => { | |
| 599 | + | const issueNum = aliceIssueUrl.split('/issues/')[1]; | |
| 600 | + | const resp = await aliceCtx.request.post( | |
| 601 | + | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, | |
| 602 | + | { form: { label_id: bugLabelId }, maxRedirects: 0 }, | |
| 603 | + | ); | |
| 604 | + | expect(resp.status()).toBe(403); | |
| 605 | + | }); | |
| 606 | + | ||
| 607 | + | test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => { | |
| 608 | + | const page = await aliceCtx.newPage(); | |
| 609 | + | try { | |
| 610 | + | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 611 | + | expect(await page.locator('.label-checkbox-list').count()).toBe(0); | |
| 612 | + | } finally { await page.close(); } | |
| 613 | + | }); | |
| 614 | + | }); | |
Atests/e2e.pagination.test.ts
| @@ -0,0 +1,200 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { | |
| 5 | + | BASE, | |
| 6 | + | ADMIN_PASS, | |
| 7 | + | setupTestEnv, | |
| 8 | + | spawnServer, | |
| 9 | + | killServer, | |
| 10 | + | login, | |
| 11 | + | } from './helpers.ts'; | |
| 12 | + | ||
| 13 | + | let browser: Browser; | |
| 14 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 15 | + | ||
| 16 | + | beforeAll(async () => { | |
| 17 | + | await setupTestEnv(); | |
| 18 | + | server = await spawnServer(); | |
| 19 | + | browser = await chromium.launch(); | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | afterAll(async () => { | |
| 23 | + | await browser.close(); | |
| 24 | + | await killServer(server); | |
| 25 | + | }); | |
| 26 | + | ||
| 27 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 28 | + | const ctx = await browser.newContext(); | |
| 29 | + | const page = await ctx.newPage(); | |
| 30 | + | await login(page, username, password); | |
| 31 | + | await page.close(); | |
| 32 | + | return ctx; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Helper: create N issues in a repo using the server API (no browser rendering) | |
| 36 | + | async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) { | |
| 37 | + | for (let i = 1; i <= count; i++) { | |
| 38 | + | await ctx.request.fetch(`${BASE}/${repo}/issues`, { | |
| 39 | + | method: 'POST', | |
| 40 | + | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 41 | + | data: `title=Issue+number+${i}&body=`, | |
| 42 | + | maxRedirects: 0, | |
| 43 | + | }).catch(() => {}); // 302 redirect throws; that's fine | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Helper: create N patches in a repo using the server API | |
| 48 | + | async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) { | |
| 49 | + | const VALID_PATCH = [ | |
| 50 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 51 | + | 'From: Test User <test@example.com>', | |
| 52 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 53 | + | 'Subject: [PATCH] Add f.txt', | |
| 54 | + | '', | |
| 55 | + | '---', | |
| 56 | + | 'diff --git a/f.txt b/f.txt', | |
| 57 | + | 'new file mode 100644', | |
| 58 | + | 'index 0000000..9daeafb', | |
| 59 | + | '--- /dev/null', | |
| 60 | + | '+++ b/f.txt', | |
| 61 | + | '@@ -0,0 +1 @@', | |
| 62 | + | '+x', | |
| 63 | + | '', | |
| 64 | + | ].join('\n'); | |
| 65 | + | ||
| 66 | + | for (let i = 1; i <= count; i++) { | |
| 67 | + | await ctx.request.fetch(`${BASE}/${repo}/patches`, { | |
| 68 | + | method: 'POST', | |
| 69 | + | multipart: { | |
| 70 | + | title: `Patch number ${i}`, | |
| 71 | + | patch_file: { name: 'bulk.patch', mimeType: 'text/plain', buffer: Buffer.from(VALID_PATCH) }, | |
| 72 | + | }, | |
| 73 | + | maxRedirects: 0, | |
| 74 | + | }).catch(() => {}); | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ─── Pagination ─────────────────────────────────────────────────────────────── | |
| 79 | + | ||
| 80 | + | describe('pagination', () => { | |
| 81 | + | let adminCtx: BrowserContext; | |
| 82 | + | ||
| 83 | + | beforeAll(async () => { | |
| 84 | + | adminCtx = await loggedInContext(); | |
| 85 | + | ||
| 86 | + | // Create a repo dedicated to pagination testing | |
| 87 | + | const page = await adminCtx.newPage(); | |
| 88 | + | try { | |
| 89 | + | await page.goto(`${BASE}/new`); | |
| 90 | + | await page.fill('[name=name]', 'paged-repo'); | |
| 91 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 92 | + | await page.waitForURL(`${BASE}/paged-repo`); | |
| 93 | + | } finally { await page.close(); } | |
| 94 | + | ||
| 95 | + | // Create 21 issues via the API (triggers page 2 at 20 per page) | |
| 96 | + | await bulkCreateIssues(adminCtx, 'paged-repo', 21); | |
| 97 | + | ||
| 98 | + | // Create 21 patches via browser (patch upload requires multipart) | |
| 99 | + | await bulkCreatePatches(adminCtx, 'paged-repo', 21); | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 103 | + | ||
| 104 | + | // ── Repo list pagination ────────────────────────────────────────────────── | |
| 105 | + | ||
| 106 | + | test('repo list page 1 shows repos and no pagination when few repos', async () => { | |
| 107 | + | // With only a handful of test repos (< 20), there should be no pagination nav | |
| 108 | + | const page = await adminCtx.newPage(); | |
| 109 | + | try { | |
| 110 | + | await page.goto(BASE); | |
| 111 | + | // Repos are shown | |
| 112 | + | expect(await page.locator('.repo-name').count()).toBeGreaterThan(0); | |
| 113 | + | } finally { await page.close(); } | |
| 114 | + | }); | |
| 115 | + | ||
| 116 | + | // ── Issue pagination ────────────────────────────────────────────────────── | |
| 117 | + | ||
| 118 | + | test('issue list page 1 shows at most 20 items', async () => { | |
| 119 | + | const page = await adminCtx.newPage(); | |
| 120 | + | try { | |
| 121 | + | await page.goto(`${BASE}/paged-repo/issues`); | |
| 122 | + | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 123 | + | } finally { await page.close(); } | |
| 124 | + | }); | |
| 125 | + | ||
| 126 | + | test('issue list pagination nav appears when more than 20 issues', async () => { | |
| 127 | + | const page = await adminCtx.newPage(); | |
| 128 | + | try { | |
| 129 | + | await page.goto(`${BASE}/paged-repo/issues`); | |
| 130 | + | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 131 | + | } finally { await page.close(); } | |
| 132 | + | }); | |
| 133 | + | ||
| 134 | + | test('issue list page 2 shows remaining issues', async () => { | |
| 135 | + | const page = await adminCtx.newPage(); | |
| 136 | + | try { | |
| 137 | + | await page.goto(`${BASE}/paged-repo/issues?page=2`); | |
| 138 | + | const count = await page.locator('.issue-item').count(); | |
| 139 | + | expect(count).toBeGreaterThan(0); | |
| 140 | + | expect(count).toBeLessThanOrEqual(20); | |
| 141 | + | } finally { await page.close(); } | |
| 142 | + | }); | |
| 143 | + | ||
| 144 | + | test('issue list page 2 prev link goes to page 1', async () => { | |
| 145 | + | const page = await adminCtx.newPage(); | |
| 146 | + | try { | |
| 147 | + | await page.goto(`${BASE}/paged-repo/issues?page=2`); | |
| 148 | + | const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href'); | |
| 149 | + | expect(prevHref).toContain('page=1'); | |
| 150 | + | } finally { await page.close(); } | |
| 151 | + | }); | |
| 152 | + | ||
| 153 | + | test('issue list page 1 next link goes to page 2', async () => { | |
| 154 | + | const page = await adminCtx.newPage(); | |
| 155 | + | try { | |
| 156 | + | await page.goto(`${BASE}/paged-repo/issues`); | |
| 157 | + | const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href'); | |
| 158 | + | expect(nextHref).toContain('page=2'); | |
| 159 | + | } finally { await page.close(); } | |
| 160 | + | }); | |
| 161 | + | ||
| 162 | + | // ── Patch pagination ────────────────────────────────────────────────────── | |
| 163 | + | ||
| 164 | + | test('patch list page 1 shows at most 20 items', async () => { | |
| 165 | + | const page = await adminCtx.newPage(); | |
| 166 | + | try { | |
| 167 | + | await page.goto(`${BASE}/paged-repo/patches`); | |
| 168 | + | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 169 | + | } finally { await page.close(); } | |
| 170 | + | }); | |
| 171 | + | ||
| 172 | + | test('patch list pagination nav appears when more than 20 patches', async () => { | |
| 173 | + | const page = await adminCtx.newPage(); | |
| 174 | + | try { | |
| 175 | + | await page.goto(`${BASE}/paged-repo/patches`); | |
| 176 | + | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 177 | + | } finally { await page.close(); } | |
| 178 | + | }); | |
| 179 | + | ||
| 180 | + | test('patch list page 2 shows remaining patches', async () => { | |
| 181 | + | const page = await adminCtx.newPage(); | |
| 182 | + | try { | |
| 183 | + | await page.goto(`${BASE}/paged-repo/patches?page=2`); | |
| 184 | + | const count = await page.locator('.issue-item').count(); | |
| 185 | + | expect(count).toBeGreaterThan(0); | |
| 186 | + | } finally { await page.close(); } | |
| 187 | + | }); | |
| 188 | + | ||
| 189 | + | // ── Commit log pagination ───────────────────────────────────────────────── | |
| 190 | + | ||
| 191 | + | test('commit log with few commits shows no cursor nav', async () => { | |
| 192 | + | // paged-repo has no commits (empty repo) — there's no commit log to paginate | |
| 193 | + | // We verify the page loads without pagination controls | |
| 194 | + | const page = await adminCtx.newPage(); | |
| 195 | + | try { | |
| 196 | + | await page.goto(`${BASE}/paged-repo`); | |
| 197 | + | expect(await page.locator('.commit-cursor-nav').count()).toBe(0); | |
| 198 | + | } finally { await page.close(); } | |
| 199 | + | }); | |
| 200 | + | }); | |
Atests/e2e.patches.test.ts
| @@ -0,0 +1,718 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { existsSync, readFileSync } from 'node:fs'; | |
| 5 | + | import { | |
| 6 | + | BASE, | |
| 7 | + | DATA_DIR, | |
| 8 | + | ADMIN_PASS, | |
| 9 | + | setupTestEnv, | |
| 10 | + | spawnServer, | |
| 11 | + | killServer, | |
| 12 | + | login, | |
| 13 | + | seedRepo, | |
| 14 | + | writeTempFile, | |
| 15 | + | gitOutput, | |
| 16 | + | } from './helpers.ts'; | |
| 17 | + | ||
| 18 | + | let browser: Browser; | |
| 19 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 20 | + | ||
| 21 | + | beforeAll(async () => { | |
| 22 | + | await setupTestEnv(); | |
| 23 | + | server = await spawnServer(); | |
| 24 | + | browser = await chromium.launch(); | |
| 25 | + | ||
| 26 | + | // Register alice | |
| 27 | + | const regCtx = await browser.newContext(); | |
| 28 | + | const regPage = await regCtx.newPage(); | |
| 29 | + | try { | |
| 30 | + | await regPage.goto(`${BASE}/register`); | |
| 31 | + | await regPage.fill('[name=username]', 'alice'); | |
| 32 | + | await regPage.fill('[name=password]', 'password123'); | |
| 33 | + | await regPage.fill('[name=password2]', 'password123'); | |
| 34 | + | await regPage.click('button[type=submit]'); | |
| 35 | + | await regPage.waitForURL(BASE + '/'); | |
| 36 | + | } finally { await regCtx.close(); } | |
| 37 | + | ||
| 38 | + | // Create my-repo | |
| 39 | + | const adminCtx = await browser.newContext(); | |
| 40 | + | const adminPage = await adminCtx.newPage(); | |
| 41 | + | try { | |
| 42 | + | await login(adminPage); | |
| 43 | + | await adminPage.goto(`${BASE}/new`); | |
| 44 | + | await adminPage.fill('[name=name]', 'my-repo'); | |
| 45 | + | await adminPage.click('form[action="/new"] button[type=submit]'); | |
| 46 | + | await adminPage.waitForURL(`${BASE}/my-repo`); | |
| 47 | + | } finally { await adminCtx.close(); } | |
| 48 | + | ||
| 49 | + | await seedRepo('my-repo'); | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | afterAll(async () => { | |
| 53 | + | await browser.close(); | |
| 54 | + | await killServer(server); | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 58 | + | const ctx = await browser.newContext(); | |
| 59 | + | const page = await ctx.newPage(); | |
| 60 | + | await login(page, username, password); | |
| 61 | + | await page.close(); | |
| 62 | + | return ctx; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // ─── Patches ────────────────────────────────────────────────────────────────── | |
| 66 | + | ||
| 67 | + | describe('patches', () => { | |
| 68 | + | let adminCtx: BrowserContext; | |
| 69 | + | let cleanPatchUrl: string; | |
| 70 | + | let conflictPatchUrl: string; | |
| 71 | + | let closePatchUrl: string; | |
| 72 | + | ||
| 73 | + | // Adds a new file — applies cleanly to my-repo | |
| 74 | + | const CLEAN_PATCH = [ | |
| 75 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 76 | + | 'From: Test User <test@example.com>', | |
| 77 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 78 | + | 'Subject: [PATCH] Add patch-test.txt', | |
| 79 | + | '', | |
| 80 | + | '---', | |
| 81 | + | 'diff --git a/patch-test.txt b/patch-test.txt', | |
| 82 | + | 'new file mode 100644', | |
| 83 | + | 'index 0000000..9daeafb', | |
| 84 | + | '--- /dev/null', | |
| 85 | + | '+++ b/patch-test.txt', | |
| 86 | + | '@@ -0,0 +1 @@', | |
| 87 | + | '+patch test content', | |
| 88 | + | '', | |
| 89 | + | ].join('\n'); | |
| 90 | + | ||
| 91 | + | // References non-existent lines in README.md — always conflicts | |
| 92 | + | const CONFLICT_PATCH = [ | |
| 93 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001', | |
| 94 | + | 'From: Test User <test@example.com>', | |
| 95 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 96 | + | 'Subject: [PATCH] Modify README', | |
| 97 | + | '', | |
| 98 | + | '---', | |
| 99 | + | 'diff --git a/README.md b/README.md', | |
| 100 | + | 'index abc1234..def5678 100644', | |
| 101 | + | '--- a/README.md', | |
| 102 | + | '+++ b/README.md', | |
| 103 | + | '@@ -50,3 +50,3 @@', | |
| 104 | + | ' nonexistent context line', | |
| 105 | + | '-nonexistent old line', | |
| 106 | + | '+nonexistent new line', | |
| 107 | + | '', | |
| 108 | + | ].join('\n'); | |
| 109 | + | ||
| 110 | + | // Adds another new file — for testing close flow | |
| 111 | + | const CLOSE_PATCH = [ | |
| 112 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001', | |
| 113 | + | 'From: Test User <test@example.com>', | |
| 114 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 115 | + | 'Subject: [PATCH] Add patch-close.txt', | |
| 116 | + | '', | |
| 117 | + | '---', | |
| 118 | + | 'diff --git a/patch-close.txt b/patch-close.txt', | |
| 119 | + | 'new file mode 100644', | |
| 120 | + | 'index 0000000..9daeafb', | |
| 121 | + | '--- /dev/null', | |
| 122 | + | '+++ b/patch-close.txt', | |
| 123 | + | '@@ -0,0 +1 @@', | |
| 124 | + | '+close test', | |
| 125 | + | '', | |
| 126 | + | ].join('\n'); | |
| 127 | + | ||
| 128 | + | beforeAll(async () => { | |
| 129 | + | adminCtx = await loggedInContext(); | |
| 130 | + | }); | |
| 131 | + | ||
| 132 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 133 | + | ||
| 134 | + | test('reject file without patch markers', async () => { | |
| 135 | + | writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text'); | |
| 136 | + | const page = await adminCtx.newPage(); | |
| 137 | + | try { | |
| 138 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 139 | + | await page.fill('[name=title]', 'Bad patch'); | |
| 140 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt'); | |
| 141 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 142 | + | expect(await page.locator('.form-error').textContent()).toContain('valid patch'); | |
| 143 | + | } finally { await page.close(); } | |
| 144 | + | }); | |
| 145 | + | ||
| 146 | + | test('reject patch missing Subject header', async () => { | |
| 147 | + | writeTempFile('/tmp/no-subject.patch', [ | |
| 148 | + | 'From: Test User <test@example.com>', | |
| 149 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 150 | + | '', | |
| 151 | + | '---', | |
| 152 | + | 'diff --git a/f.txt b/f.txt', | |
| 153 | + | 'new file mode 100644', | |
| 154 | + | '--- /dev/null', | |
| 155 | + | '+++ b/f.txt', | |
| 156 | + | '@@ -0,0 +1 @@', | |
| 157 | + | '+x', | |
| 158 | + | '', | |
| 159 | + | ].join('\n')); | |
| 160 | + | const page = await adminCtx.newPage(); | |
| 161 | + | try { | |
| 162 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 163 | + | await page.fill('[name=title]', 'No subject'); | |
| 164 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch'); | |
| 165 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 166 | + | expect(await page.locator('.form-error').textContent()).toContain('Subject'); | |
| 167 | + | } finally { await page.close(); } | |
| 168 | + | }); | |
| 169 | + | ||
| 170 | + | test('reject patch missing From header', async () => { | |
| 171 | + | writeTempFile('/tmp/no-from.patch', [ | |
| 172 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 173 | + | 'Subject: [PATCH] Add f.txt', | |
| 174 | + | '', | |
| 175 | + | '---', | |
| 176 | + | 'diff --git a/f.txt b/f.txt', | |
| 177 | + | 'new file mode 100644', | |
| 178 | + | '--- /dev/null', | |
| 179 | + | '+++ b/f.txt', | |
| 180 | + | '@@ -0,0 +1 @@', | |
| 181 | + | '+x', | |
| 182 | + | '', | |
| 183 | + | ].join('\n')); | |
| 184 | + | const page = await adminCtx.newPage(); | |
| 185 | + | try { | |
| 186 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 187 | + | await page.fill('[name=title]', 'No from'); | |
| 188 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch'); | |
| 189 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 190 | + | expect(await page.locator('.form-error').textContent()).toContain('From'); | |
| 191 | + | } finally { await page.close(); } | |
| 192 | + | }); | |
| 193 | + | ||
| 194 | + | test('reject patch missing Date header', async () => { | |
| 195 | + | writeTempFile('/tmp/no-date.patch', [ | |
| 196 | + | 'From: Test User <test@example.com>', | |
| 197 | + | 'Subject: [PATCH] Add f.txt', | |
| 198 | + | '', | |
| 199 | + | '---', | |
| 200 | + | 'diff --git a/f.txt b/f.txt', | |
| 201 | + | 'new file mode 100644', | |
| 202 | + | '--- /dev/null', | |
| 203 | + | '+++ b/f.txt', | |
| 204 | + | '@@ -0,0 +1 @@', | |
| 205 | + | '+x', | |
| 206 | + | '', | |
| 207 | + | ].join('\n')); | |
| 208 | + | const page = await adminCtx.newPage(); | |
| 209 | + | try { | |
| 210 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 211 | + | await page.fill('[name=title]', 'No date'); | |
| 212 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch'); | |
| 213 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 214 | + | expect(await page.locator('.form-error').textContent()).toContain('Date'); | |
| 215 | + | } finally { await page.close(); } | |
| 216 | + | }); | |
| 217 | + | ||
| 218 | + | test('upload clean patch', async () => { | |
| 219 | + | writeTempFile('/tmp/clean.patch', CLEAN_PATCH); | |
| 220 | + | const page = await adminCtx.newPage(); | |
| 221 | + | try { | |
| 222 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 223 | + | await page.fill('[name=title]', 'Add patch-test.txt'); | |
| 224 | + | await page.fill('[name=description]', 'Adds a file with **markdown** desc.'); | |
| 225 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch'); | |
| 226 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 227 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 228 | + | cleanPatchUrl = page.url(); | |
| 229 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt'); | |
| 230 | + | } finally { await page.close(); } | |
| 231 | + | }); | |
| 232 | + | ||
| 233 | + | test('changes tab shows commit metadata card', async () => { | |
| 234 | + | const page = await adminCtx.newPage(); | |
| 235 | + | try { | |
| 236 | + | await page.goto(cleanPatchUrl + '?tab=changes'); | |
| 237 | + | expect(await page.locator('.commit-card').isVisible()).toBe(true); | |
| 238 | + | expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt'); | |
| 239 | + | expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User'); | |
| 240 | + | expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com'); | |
| 241 | + | expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true); | |
| 242 | + | } finally { await page.close(); } | |
| 243 | + | }); | |
| 244 | + | ||
| 245 | + | test('patch description renders markdown', async () => { | |
| 246 | + | const page = await adminCtx.newPage(); | |
| 247 | + | try { | |
| 248 | + | await page.goto(cleanPatchUrl); | |
| 249 | + | expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>'); | |
| 250 | + | } finally { await page.close(); } | |
| 251 | + | }); | |
| 252 | + | ||
| 253 | + | test('clean patch shows apply-clean status immediately', async () => { | |
| 254 | + | const page = await adminCtx.newPage(); | |
| 255 | + | try { | |
| 256 | + | await page.goto(cleanPatchUrl); | |
| 257 | + | expect(await page.locator('.apply-result').isVisible()).toBe(true); | |
| 258 | + | expect(await page.locator('.apply-clean').isVisible()).toBe(true); | |
| 259 | + | } finally { await page.close(); } | |
| 260 | + | }); | |
| 261 | + | ||
| 262 | + | test('merge button appears for clean patch', async () => { | |
| 263 | + | const page = await adminCtx.newPage(); | |
| 264 | + | try { | |
| 265 | + | await page.goto(cleanPatchUrl); | |
| 266 | + | expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true); | |
| 267 | + | } finally { await page.close(); } | |
| 268 | + | }); | |
| 269 | + | ||
| 270 | + | test('patch diff is displayed with highlighted table', async () => { | |
| 271 | + | const page = await adminCtx.newPage(); | |
| 272 | + | try { | |
| 273 | + | await page.goto(cleanPatchUrl + '?tab=changes'); | |
| 274 | + | expect(await page.locator('.diff-table').first().isVisible()).toBe(true); | |
| 275 | + | expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0); | |
| 276 | + | } finally { await page.close(); } | |
| 277 | + | }); | |
| 278 | + | ||
| 279 | + | test('patch appears in open list', async () => { | |
| 280 | + | const page = await adminCtx.newPage(); | |
| 281 | + | try { | |
| 282 | + | await page.goto(`${BASE}/my-repo/patches`); | |
| 283 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 284 | + | expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true); | |
| 285 | + | } finally { await page.close(); } | |
| 286 | + | }); | |
| 287 | + | ||
| 288 | + | test('unauthenticated user is redirected to login from patch upload', async () => { | |
| 289 | + | const ctx = await browser.newContext(); | |
| 290 | + | const page = await ctx.newPage(); | |
| 291 | + | try { | |
| 292 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 293 | + | expect(page.url()).toContain('/login'); | |
| 294 | + | } finally { await ctx.close(); } | |
| 295 | + | }); | |
| 296 | + | ||
| 297 | + | test('upload conflict patch', async () => { | |
| 298 | + | writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH); | |
| 299 | + | const page = await adminCtx.newPage(); | |
| 300 | + | try { | |
| 301 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 302 | + | await page.fill('[name=title]', 'Conflict patch'); | |
| 303 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch'); | |
| 304 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 305 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 306 | + | conflictPatchUrl = page.url(); | |
| 307 | + | } finally { await page.close(); } | |
| 308 | + | }); | |
| 309 | + | ||
| 310 | + | test('conflict patch shows apply-conflict status', async () => { | |
| 311 | + | const page = await adminCtx.newPage(); | |
| 312 | + | try { | |
| 313 | + | await page.goto(conflictPatchUrl); | |
| 314 | + | expect(await page.locator('.apply-conflict').isVisible()).toBe(true); | |
| 315 | + | } finally { await page.close(); } | |
| 316 | + | }); | |
| 317 | + | ||
| 318 | + | test('merge button absent for conflict patch', async () => { | |
| 319 | + | const page = await adminCtx.newPage(); | |
| 320 | + | try { | |
| 321 | + | await page.goto(conflictPatchUrl); | |
| 322 | + | expect(await page.locator('form[action*="/merge"] button').count()).toBe(0); | |
| 323 | + | } finally { await page.close(); } | |
| 324 | + | }); | |
| 325 | + | ||
| 326 | + | test('merge clean patch changes status to merged', async () => { | |
| 327 | + | const page = await adminCtx.newPage(); | |
| 328 | + | try { | |
| 329 | + | await page.goto(cleanPatchUrl); | |
| 330 | + | await page.click('form[action*="/merge"] button'); | |
| 331 | + | await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, ''))); | |
| 332 | + | expect(await page.locator('.patch-badge').textContent()).toBe('merged'); | |
| 333 | + | } finally { await page.close(); } | |
| 334 | + | }); | |
| 335 | + | ||
| 336 | + | test('merge uses patch From header as git author', async () => { | |
| 337 | + | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 338 | + | const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath); | |
| 339 | + | const authorEmail = gitOutput(['log', '-1', '--format=%aE'], repoPath); | |
| 340 | + | const subject = gitOutput(['log', '-1', '--format=%s'], repoPath); | |
| 341 | + | expect(authorName).toBe('Test User'); | |
| 342 | + | expect(authorEmail).toBe('test@example.com'); | |
| 343 | + | expect(subject).toBe('Add patch-test.txt'); | |
| 344 | + | }); | |
| 345 | + | ||
| 346 | + | test('merged patch appears in merged list', async () => { | |
| 347 | + | const page = await adminCtx.newPage(); | |
| 348 | + | try { | |
| 349 | + | await page.goto(`${BASE}/my-repo/patches?status=merged`); | |
| 350 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 351 | + | expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true); | |
| 352 | + | } finally { await page.close(); } | |
| 353 | + | }); | |
| 354 | + | ||
| 355 | + | test('upload and close a patch', async () => { | |
| 356 | + | writeTempFile('/tmp/close.patch', CLOSE_PATCH); | |
| 357 | + | const page = await adminCtx.newPage(); | |
| 358 | + | try { | |
| 359 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 360 | + | await page.fill('[name=title]', 'Close me'); | |
| 361 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch'); | |
| 362 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 363 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 364 | + | closePatchUrl = page.url(); | |
| 365 | + | await page.click('form[action*="/close"] button'); | |
| 366 | + | await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, ''))); | |
| 367 | + | expect(await page.locator('.patch-badge').textContent()).toBe('closed'); | |
| 368 | + | } finally { await page.close(); } | |
| 369 | + | }); | |
| 370 | + | ||
| 371 | + | test('closed patch appears in closed list', async () => { | |
| 372 | + | const page = await adminCtx.newPage(); | |
| 373 | + | try { | |
| 374 | + | await page.goto(`${BASE}/my-repo/patches?status=closed`); | |
| 375 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 376 | + | expect(titles.some(t => t.includes('Close me'))).toBe(true); | |
| 377 | + | } finally { await page.close(); } | |
| 378 | + | }); | |
| 379 | + | ||
| 380 | + | test('closed patch can be reopened', async () => { | |
| 381 | + | const page = await adminCtx.newPage(); | |
| 382 | + | try { | |
| 383 | + | await page.goto(closePatchUrl); | |
| 384 | + | expect(await page.locator('.patch-badge').textContent()).toBe('closed'); | |
| 385 | + | await page.click('form[action*="/close"] button'); | |
| 386 | + | await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, ''))); | |
| 387 | + | expect(await page.locator('.patch-badge').textContent()).toBe('open'); | |
| 388 | + | } finally { await page.close(); } | |
| 389 | + | }); | |
| 390 | + | ||
| 391 | + | test('patch title and description can be edited', async () => { | |
| 392 | + | const page = await adminCtx.newPage(); | |
| 393 | + | try { | |
| 394 | + | await page.goto(conflictPatchUrl); | |
| 395 | + | // Edit title via title form | |
| 396 | + | await page.click('details.title-edit-details summary'); | |
| 397 | + | await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch'); | |
| 398 | + | await page.click('.title-edit-form-area [type=submit]'); | |
| 399 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 400 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch'); | |
| 401 | + | // Edit description via inline form | |
| 402 | + | await page.click('.timeline-author .inline-edit-details summary'); | |
| 403 | + | await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc'); | |
| 404 | + | await page.click('.inline-edit-form-area [type=submit]'); | |
| 405 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 406 | + | } finally { await page.close(); } | |
| 407 | + | }); | |
| 408 | + | ||
| 409 | + | test('patch comment: add and edit', async () => { | |
| 410 | + | const page = await adminCtx.newPage(); | |
| 411 | + | try { | |
| 412 | + | await page.goto(conflictPatchUrl); | |
| 413 | + | await page.fill('[name=body]', 'My patch comment'); | |
| 414 | + | await page.click('form[action$="/comments"] button[type=submit]'); | |
| 415 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 416 | + | expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment'); | |
| 417 | + | ||
| 418 | + | // Edit the comment — scope to the timeline-item containing the comment text | |
| 419 | + | const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' }); | |
| 420 | + | await commentItem.locator('.inline-edit-details summary').click(); | |
| 421 | + | await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment'); | |
| 422 | + | await commentItem.locator('.inline-edit-form-area [type=submit]').click(); | |
| 423 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 424 | + | expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment'); | |
| 425 | + | } finally { await page.close(); } | |
| 426 | + | }); | |
| 427 | + | ||
| 428 | + | test('patch reaction on description', async () => { | |
| 429 | + | const page = await adminCtx.newPage(); | |
| 430 | + | try { | |
| 431 | + | await page.goto(conflictPatchUrl); | |
| 432 | + | // Open reaction picker on the first timeline-item (description) | |
| 433 | + | await page.locator('.timeline-item').first().locator('.reaction-add-btn').click(); | |
| 434 | + | await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click(); | |
| 435 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 436 | + | expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/); | |
| 437 | + | } finally { await page.close(); } | |
| 438 | + | }); | |
| 439 | + | ||
| 440 | + | test('admin can delete a patch', async () => { | |
| 441 | + | const page = await adminCtx.newPage(); | |
| 442 | + | try { | |
| 443 | + | const patchNum = conflictPatchUrl.split('/patches/')[1]; | |
| 444 | + | const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, { | |
| 445 | + | maxRedirects: 0, | |
| 446 | + | }); | |
| 447 | + | expect(resp.status()).toBe(302); | |
| 448 | + | expect(resp.headers()['location']).toContain('/patches'); | |
| 449 | + | // Patch should no longer be accessible | |
| 450 | + | const checkResp = await page.request.get(conflictPatchUrl); | |
| 451 | + | expect(checkResp.status()).toBe(404); | |
| 452 | + | } finally { await page.close(); } | |
| 453 | + | }); | |
| 454 | + | ||
| 455 | + | // ── Patch file re-upload & version protection ────────────────────────────── | |
| 456 | + | ||
| 457 | + | let uploadTestPatchUrl: string; | |
| 458 | + | ||
| 459 | + | // Adds upload-test.txt — applies cleanly to my-repo | |
| 460 | + | const UPLOAD_TEST_PATCH = [ | |
| 461 | + | 'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001', | |
| 462 | + | 'From: Original Author <original@example.com>', | |
| 463 | + | 'Date: Wed, 03 Jan 2024 10:00:00 +0000', | |
| 464 | + | 'Subject: [PATCH] Add upload-test.txt', | |
| 465 | + | '', | |
| 466 | + | '---', | |
| 467 | + | 'diff --git a/upload-test.txt b/upload-test.txt', | |
| 468 | + | 'new file mode 100644', | |
| 469 | + | 'index 0000000..9daeafb', | |
| 470 | + | '--- /dev/null', | |
| 471 | + | '+++ b/upload-test.txt', | |
| 472 | + | '@@ -0,0 +1 @@', | |
| 473 | + | '+upload test', | |
| 474 | + | '', | |
| 475 | + | ].join('\n'); | |
| 476 | + | ||
| 477 | + | // Replacement: different author, same diff target | |
| 478 | + | const REPLACEMENT_PATCH = [ | |
| 479 | + | 'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001', | |
| 480 | + | 'From: Replaced Author <replaced@example.com>', | |
| 481 | + | 'Date: Thu, 04 Jan 2024 10:00:00 +0000', | |
| 482 | + | 'Subject: [PATCH] Add upload-test.txt (v2)', | |
| 483 | + | '', | |
| 484 | + | '---', | |
| 485 | + | 'diff --git a/upload-test.txt b/upload-test.txt', | |
| 486 | + | 'new file mode 100644', | |
| 487 | + | 'index 0000000..9daeafb', | |
| 488 | + | '--- /dev/null', | |
| 489 | + | '+++ b/upload-test.txt', | |
| 490 | + | '@@ -0,0 +1 @@', | |
| 491 | + | '+upload test v2', | |
| 492 | + | '', | |
| 493 | + | ].join('\n'); | |
| 494 | + | ||
| 495 | + | test('create patch for re-upload tests', async () => { | |
| 496 | + | writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH); | |
| 497 | + | const page = await adminCtx.newPage(); | |
| 498 | + | try { | |
| 499 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 500 | + | await page.fill('[name=title]', 'Upload test patch'); | |
| 501 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch'); | |
| 502 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 503 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 504 | + | uploadTestPatchUrl = page.url(); | |
| 505 | + | } finally { await page.close(); } | |
| 506 | + | }); | |
| 507 | + | ||
| 508 | + | test('upload patch file button is visible for admin on open patch', async () => { | |
| 509 | + | const page = await adminCtx.newPage(); | |
| 510 | + | try { | |
| 511 | + | await page.goto(uploadTestPatchUrl); | |
| 512 | + | expect(await page.locator('details:has([name=patch_file])').count()).toBe(1); | |
| 513 | + | } finally { await page.close(); } | |
| 514 | + | }); | |
| 515 | + | ||
| 516 | + | test('non-author non-admin cannot upload patch file', async () => { | |
| 517 | + | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 518 | + | const page = await aliceCtx.newPage(); | |
| 519 | + | try { | |
| 520 | + | const patchNum = uploadTestPatchUrl.split('/patches/')[1]; | |
| 521 | + | const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, { | |
| 522 | + | multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } }, | |
| 523 | + | maxRedirects: 0, | |
| 524 | + | }); | |
| 525 | + | expect(resp.status()).toBe(403); | |
| 526 | + | } finally { await aliceCtx.close(); } | |
| 527 | + | }); | |
| 528 | + | ||
| 529 | + | test('upload button hidden for non-author non-admin', async () => { | |
| 530 | + | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 531 | + | const page = await aliceCtx.newPage(); | |
| 532 | + | try { | |
| 533 | + | await page.goto(uploadTestPatchUrl); | |
| 534 | + | expect(await page.locator('details:has([name=patch_file])').count()).toBe(0); | |
| 535 | + | } finally { await aliceCtx.close(); } | |
| 536 | + | }); | |
| 537 | + | ||
| 538 | + | test('admin can upload replacement patch file', async () => { | |
| 539 | + | writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH); | |
| 540 | + | const page = await adminCtx.newPage(); | |
| 541 | + | try { | |
| 542 | + | await page.goto(uploadTestPatchUrl); | |
| 543 | + | await page.locator('details:has([name=patch_file]) summary').click(); | |
| 544 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch'); | |
| 545 | + | await page.locator('details:has([name=patch_file]) button[type=submit]').click(); | |
| 546 | + | await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, ''))); | |
| 547 | + | } finally { await page.close(); } | |
| 548 | + | }); | |
| 549 | + | ||
| 550 | + | test('merge fails when version token is stale', async () => { | |
| 551 | + | // Patch that adds stale-version.txt — applies cleanly | |
| 552 | + | const STALE_PATCH = [ | |
| 553 | + | 'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001', | |
| 554 | + | 'From: Test User <test@example.com>', | |
| 555 | + | 'Date: Fri, 05 Jan 2024 10:00:00 +0000', | |
| 556 | + | 'Subject: [PATCH] Add stale-version.txt', | |
| 557 | + | '', | |
| 558 | + | '---', | |
| 559 | + | 'diff --git a/stale-version.txt b/stale-version.txt', | |
| 560 | + | 'new file mode 100644', | |
| 561 | + | 'index 0000000..9daeafb', | |
| 562 | + | '--- /dev/null', | |
| 563 | + | '+++ b/stale-version.txt', | |
| 564 | + | '@@ -0,0 +1 @@', | |
| 565 | + | '+stale', | |
| 566 | + | '', | |
| 567 | + | ].join('\n'); | |
| 568 | + | const STALE_PATCH_V2 = STALE_PATCH | |
| 569 | + | .replace('Add stale-version.txt', 'Add stale-version.txt (v2)') | |
| 570 | + | .replace('+stale', '+stale v2'); | |
| 571 | + | ||
| 572 | + | writeTempFile('/tmp/stale.patch', STALE_PATCH); | |
| 573 | + | const page = await adminCtx.newPage(); | |
| 574 | + | try { | |
| 575 | + | // Create the patch | |
| 576 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 577 | + | await page.fill('[name=title]', 'Stale version test'); | |
| 578 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch'); | |
| 579 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 580 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 581 | + | const stalePatchUrl = page.url(); | |
| 582 | + | const patchNum = stalePatchUrl.split('/patches/')[1]; | |
| 583 | + | ||
| 584 | + | // Capture the version the admin sees on the page | |
| 585 | + | const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue(); | |
| 586 | + | ||
| 587 | + | // Author uploads a new patch file (simulated by admin here), bumping the version | |
| 588 | + | writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2); | |
| 589 | + | await page.locator('details:has([name=patch_file]) summary').click(); | |
| 590 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch'); | |
| 591 | + | await page.locator('details:has([name=patch_file]) button[type=submit]').click(); | |
| 592 | + | await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, ''))); | |
| 593 | + | ||
| 594 | + | // Admin tries to merge with the stale version — should be rejected | |
| 595 | + | const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, { | |
| 596 | + | form: { version: staleVersion }, | |
| 597 | + | maxRedirects: 0, | |
| 598 | + | }); | |
| 599 | + | expect(resp.status()).toBe(409); | |
| 600 | + | expect(await resp.text()).toContain('updated'); | |
| 601 | + | ||
| 602 | + | // Patch status must still be open | |
| 603 | + | const checkResp = await page.request.get(stalePatchUrl); | |
| 604 | + | expect(checkResp.status()).toBe(200); | |
| 605 | + | expect(await checkResp.text()).toContain('open'); | |
| 606 | + | } finally { await page.close(); } | |
| 607 | + | }); | |
| 608 | + | ||
| 609 | + | test('merge succeeds with current version token after replacement upload', async () => { | |
| 610 | + | const page = await adminCtx.newPage(); | |
| 611 | + | try { | |
| 612 | + | await page.goto(uploadTestPatchUrl); | |
| 613 | + | await page.click('form[action*="/merge"] button'); | |
| 614 | + | await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, ''))); | |
| 615 | + | expect(await page.locator('.patch-badge').textContent()).toBe('merged'); | |
| 616 | + | // Merged commit should carry the replacement patch's author | |
| 617 | + | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 618 | + | const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath); | |
| 619 | + | expect(authorName).toBe('Replaced Author'); | |
| 620 | + | } finally { await page.close(); } | |
| 621 | + | }); | |
| 622 | + | ||
| 623 | + | test('upload patch file button hidden on merged patch', async () => { | |
| 624 | + | const page = await adminCtx.newPage(); | |
| 625 | + | try { | |
| 626 | + | await page.goto(uploadTestPatchUrl); | |
| 627 | + | expect(await page.locator('details:has([name=patch_file])').count()).toBe(0); | |
| 628 | + | } finally { await page.close(); } | |
| 629 | + | }); | |
| 630 | + | ||
| 631 | + | test('POST to upload on merged patch returns 400', async () => { | |
| 632 | + | const patchNum = uploadTestPatchUrl.split('/patches/')[1]; | |
| 633 | + | const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, { | |
| 634 | + | multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } }, | |
| 635 | + | maxRedirects: 0, | |
| 636 | + | }); | |
| 637 | + | expect(resp.status()).toBe(400); | |
| 638 | + | }); | |
| 639 | + | }); | |
| 640 | + | ||
| 641 | + | // ─── Commit signing ─────────────────────────────────────────────────────────── | |
| 642 | + | // Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo. | |
| 643 | + | ||
| 644 | + | describe('commit signing', () => { | |
| 645 | + | let adminCtx: BrowserContext; | |
| 646 | + | ||
| 647 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 648 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 649 | + | ||
| 650 | + | test('allowed_signers file is generated at startup', () => { | |
| 651 | + | const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`; | |
| 652 | + | expect(existsSync(allowedSignersPath)).toBe(true); | |
| 653 | + | const content = readFileSync(allowedSignersPath, 'utf8'); | |
| 654 | + | expect(content).toContain('namespaces="git"'); | |
| 655 | + | expect(content).toContain('ssh-ed25519'); | |
| 656 | + | }); | |
| 657 | + | ||
| 658 | + | test('merged commit has a gpgsig header', async () => { | |
| 659 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 660 | + | // Find the patch commit specifically by subject | |
| 661 | + | const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir); | |
| 662 | + | expect(hash).toBeTruthy(); | |
| 663 | + | const obj = gitOutput(['cat-file', '-p', hash], repoDir); | |
| 664 | + | expect(obj).toContain('gpgsig'); | |
| 665 | + | }); | |
| 666 | + | ||
| 667 | + | test('unsigned commits have no gpgsig header', async () => { | |
| 668 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 669 | + | // Initial commit was created by seedRepo (plain git commit, not hearthforge) | |
| 670 | + | const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir); | |
| 671 | + | expect(hash).toBeTruthy(); | |
| 672 | + | const obj = gitOutput(['cat-file', '-p', hash], repoDir); | |
| 673 | + | expect(obj).not.toContain('gpgsig'); | |
| 674 | + | }); | |
| 675 | + | ||
| 676 | + | test('commit log shows verified badge on signed commit', async () => { | |
| 677 | + | const page = await adminCtx.newPage(); | |
| 678 | + | try { | |
| 679 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 680 | + | // Find the commit-item for the merged patch by subject text | |
| 681 | + | const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' }); | |
| 682 | + | expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 683 | + | } finally { await page.close(); } | |
| 684 | + | }); | |
| 685 | + | ||
| 686 | + | test('commit log shows no sig badge on unsigned commit', async () => { | |
| 687 | + | const page = await adminCtx.newPage(); | |
| 688 | + | try { | |
| 689 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 690 | + | // Initial commit was not signed via hearthforge | |
| 691 | + | const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' }); | |
| 692 | + | expect(await initialItem.locator('.sig-badge').count()).toBe(0); | |
| 693 | + | } finally { await page.close(); } | |
| 694 | + | }); | |
| 695 | + | ||
| 696 | + | test('commit detail shows verified signature row for signed commit', async () => { | |
| 697 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 698 | + | const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir); | |
| 699 | + | const page = await adminCtx.newPage(); | |
| 700 | + | try { | |
| 701 | + | await page.goto(`${BASE}/my-repo/commit/${hash}`); | |
| 702 | + | const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' }); | |
| 703 | + | expect(await sigRow.isVisible()).toBe(true); | |
| 704 | + | expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 705 | + | } finally { await page.close(); } | |
| 706 | + | }); | |
| 707 | + | ||
| 708 | + | test('commit detail shows no signature row for unsigned commit', async () => { | |
| 709 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 710 | + | const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir); | |
| 711 | + | const page = await adminCtx.newPage(); | |
| 712 | + | try { | |
| 713 | + | await page.goto(`${BASE}/my-repo/commit/${hash}`); | |
| 714 | + | const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' }); | |
| 715 | + | expect(await sigRow.count()).toBe(0); | |
| 716 | + | } finally { await page.close(); } | |
| 717 | + | }); | |
| 718 | + | }); | |
Atests/e2e.releases.test.ts
| @@ -0,0 +1,361 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { | |
| 5 | + | BASE, | |
| 6 | + | ADMIN_PASS, | |
| 7 | + | setupTestEnv, | |
| 8 | + | spawnServer, | |
| 9 | + | killServer, | |
| 10 | + | login, | |
| 11 | + | seedRepo, | |
| 12 | + | } from './helpers.ts'; | |
| 13 | + | ||
| 14 | + | let browser: Browser; | |
| 15 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 16 | + | ||
| 17 | + | beforeAll(async () => { | |
| 18 | + | await setupTestEnv(); | |
| 19 | + | server = await spawnServer(); | |
| 20 | + | browser = await chromium.launch(); | |
| 21 | + | ||
| 22 | + | // Register alice | |
| 23 | + | const regCtx = await browser.newContext(); | |
| 24 | + | const regPage = await regCtx.newPage(); | |
| 25 | + | try { | |
| 26 | + | await regPage.goto(`${BASE}/register`); | |
| 27 | + | await regPage.fill('[name=username]', 'alice'); | |
| 28 | + | await regPage.fill('[name=password]', 'password123'); | |
| 29 | + | await regPage.fill('[name=password2]', 'password123'); | |
| 30 | + | await regPage.click('button[type=submit]'); | |
| 31 | + | await regPage.waitForURL(BASE + '/'); | |
| 32 | + | } finally { await regCtx.close(); } | |
| 33 | + | }); | |
| 34 | + | ||
| 35 | + | afterAll(async () => { | |
| 36 | + | await browser.close(); | |
| 37 | + | await killServer(server); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 41 | + | const ctx = await browser.newContext(); | |
| 42 | + | const page = await ctx.newPage(); | |
| 43 | + | await login(page, username, password); | |
| 44 | + | await page.close(); | |
| 45 | + | return ctx; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ─── Releases ───────────────────────────────────────────────────────────────── | |
| 49 | + | ||
| 50 | + | describe('releases', () => { | |
| 51 | + | let adminCtx: BrowserContext; | |
| 52 | + | let aliceCtx: BrowserContext; | |
| 53 | + | let releaseUrl: string; | |
| 54 | + | let srcReleaseUrl: string; | |
| 55 | + | let releaseWithAssetsUrl: string; | |
| 56 | + | ||
| 57 | + | beforeAll(async () => { | |
| 58 | + | adminCtx = await loggedInContext(); | |
| 59 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 60 | + | ||
| 61 | + | // Create a dedicated repo with at least one commit | |
| 62 | + | const page = await adminCtx.newPage(); | |
| 63 | + | try { | |
| 64 | + | await page.goto(`${BASE}/new`); | |
| 65 | + | await page.fill('[name=name]', 'releases-repo'); | |
| 66 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 67 | + | await page.waitForURL(`${BASE}/releases-repo`); | |
| 68 | + | } finally { await page.close(); } | |
| 69 | + | ||
| 70 | + | await seedRepo('releases-repo'); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | afterAll(async () => { | |
| 74 | + | await adminCtx.close(); | |
| 75 | + | await aliceCtx.close(); | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | // ── Navigation ────────────────────────────────────────────────────────────── | |
| 79 | + | ||
| 80 | + | test('releases tab visible in repo nav', async () => { | |
| 81 | + | const page = await adminCtx.newPage(); | |
| 82 | + | try { | |
| 83 | + | await page.goto(`${BASE}/releases-repo`); | |
| 84 | + | expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true); | |
| 85 | + | } finally { await page.close(); } | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test('releases list shows empty state when no releases', async () => { | |
| 89 | + | const page = await adminCtx.newPage(); | |
| 90 | + | try { | |
| 91 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 92 | + | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 93 | + | } finally { await page.close(); } | |
| 94 | + | }); | |
| 95 | + | ||
| 96 | + | // ── Access control ────────────────────────────────────────────────────────── | |
| 97 | + | ||
| 98 | + | test('non-admin cannot access /releases/new', async () => { | |
| 99 | + | const page = await aliceCtx.newPage(); | |
| 100 | + | try { | |
| 101 | + | const resp = await page.request.get(`${BASE}/releases-repo/releases/new`); | |
| 102 | + | expect(resp.status()).toBe(403); | |
| 103 | + | } finally { await page.close(); } | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test('non-admin POST to /releases returns 403', async () => { | |
| 107 | + | const page = await aliceCtx.newPage(); | |
| 108 | + | try { | |
| 109 | + | const resp = await page.request.post(`${BASE}/releases-repo/releases`, { | |
| 110 | + | multipart: { name: 'Test', tag_name: 'v0.1.0' }, | |
| 111 | + | maxRedirects: 0, | |
| 112 | + | }); | |
| 113 | + | expect(resp.status()).toBe(403); | |
| 114 | + | } finally { await page.close(); } | |
| 115 | + | }); | |
| 116 | + | ||
| 117 | + | test('unauthenticated user is redirected to login from /releases/new', async () => { | |
| 118 | + | const ctx = await browser.newContext(); | |
| 119 | + | const page = await ctx.newPage(); | |
| 120 | + | try { | |
| 121 | + | await page.goto(`${BASE}/releases-repo/releases/new`); | |
| 122 | + | expect(page.url()).toContain('/login'); | |
| 123 | + | } finally { await ctx.close(); } | |
| 124 | + | }); | |
| 125 | + | ||
| 126 | + | // ── Validation ────────────────────────────────────────────────────────────── | |
| 127 | + | ||
| 128 | + | test('missing release title shows error', async () => { | |
| 129 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 130 | + | multipart: {}, | |
| 131 | + | }); | |
| 132 | + | expect(await resp.text()).toContain('Release title is required'); | |
| 133 | + | }); | |
| 134 | + | ||
| 135 | + | test('create_tag checked but no tag name shows error', async () => { | |
| 136 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 137 | + | multipart: { name: 'Test', create_tag: 'on' }, | |
| 138 | + | }); | |
| 139 | + | expect(await resp.text()).toContain('Tag name is required'); | |
| 140 | + | }); | |
| 141 | + | ||
| 142 | + | // ── Create ────────────────────────────────────────────────────────────────── | |
| 143 | + | ||
| 144 | + | test('create a basic release', async () => { | |
| 145 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 146 | + | multipart: { | |
| 147 | + | create_tag: 'on', | |
| 148 | + | tag_name: 'v1.0.0', | |
| 149 | + | revision: 'main', | |
| 150 | + | name: 'First release', | |
| 151 | + | notes: 'Initial stable release.\n\n- Feature A\n- Feature B', | |
| 152 | + | }, | |
| 153 | + | maxRedirects: 0, | |
| 154 | + | }); | |
| 155 | + | expect(resp.status()).toBe(302); | |
| 156 | + | const location = resp.headers()['location']!; | |
| 157 | + | expect(location).toMatch(/\/releases-repo\/releases\/\d+/); | |
| 158 | + | releaseUrl = `${BASE}${location}`; | |
| 159 | + | }); | |
| 160 | + | ||
| 161 | + | test('duplicate tag name shows error', async () => { | |
| 162 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 163 | + | multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' }, | |
| 164 | + | }); | |
| 165 | + | expect(await resp.text()).toContain('already exists'); | |
| 166 | + | }); | |
| 167 | + | ||
| 168 | + | // ── List ──────────────────────────────────────────────────────────────────── | |
| 169 | + | ||
| 170 | + | test('release appears in list with tag badge', async () => { | |
| 171 | + | const page = await adminCtx.newPage(); | |
| 172 | + | try { | |
| 173 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 174 | + | expect(await page.locator('.release-item-title').textContent()).toContain('First release'); | |
| 175 | + | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); | |
| 176 | + | } finally { await page.close(); } | |
| 177 | + | }); | |
| 178 | + | ||
| 179 | + | test('release list shows tag badge', async () => { | |
| 180 | + | const page = await adminCtx.newPage(); | |
| 181 | + | try { | |
| 182 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 183 | + | expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0'); | |
| 184 | + | } finally { await page.close(); } | |
| 185 | + | }); | |
| 186 | + | ||
| 187 | + | test('new release button hidden for non-admin', async () => { | |
| 188 | + | const page = await aliceCtx.newPage(); | |
| 189 | + | try { | |
| 190 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 191 | + | expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0); | |
| 192 | + | } finally { await page.close(); } | |
| 193 | + | }); | |
| 194 | + | ||
| 195 | + | // ── Detail ────────────────────────────────────────────────────────────────── | |
| 196 | + | ||
| 197 | + | test('release detail shows title and tag badge', async () => { | |
| 198 | + | const page = await adminCtx.newPage(); | |
| 199 | + | try { | |
| 200 | + | await page.goto(releaseUrl); | |
| 201 | + | expect(await page.locator('h2.page-title').textContent()).toBe('First release'); | |
| 202 | + | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); | |
| 203 | + | } finally { await page.close(); } | |
| 204 | + | }); | |
| 205 | + | ||
| 206 | + | test('release notes rendered in detail view', async () => { | |
| 207 | + | const page = await adminCtx.newPage(); | |
| 208 | + | try { | |
| 209 | + | await page.goto(releaseUrl); | |
| 210 | + | expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release'); | |
| 211 | + | } finally { await page.close(); } | |
| 212 | + | }); | |
| 213 | + | ||
| 214 | + | // ── Source archives ───────────────────────────────────────────────────────── | |
| 215 | + | ||
| 216 | + | test('create release with source code archives', async () => { | |
| 217 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 218 | + | multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' }, | |
| 219 | + | maxRedirects: 0, | |
| 220 | + | }); | |
| 221 | + | expect(resp.status()).toBe(302); | |
| 222 | + | const location = resp.headers()['location']!; | |
| 223 | + | srcReleaseUrl = `${BASE}${location}`; | |
| 224 | + | }); | |
| 225 | + | ||
| 226 | + | test('zip and tar.gz archives appear in downloads', async () => { | |
| 227 | + | const page = await adminCtx.newPage(); | |
| 228 | + | try { | |
| 229 | + | await page.goto(srcReleaseUrl); | |
| 230 | + | const assetNames = await page.locator('.asset-name').allTextContents(); | |
| 231 | + | expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true); | |
| 232 | + | expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true); | |
| 233 | + | } finally { await page.close(); } | |
| 234 | + | }); | |
| 235 | + | ||
| 236 | + | test('source archive download responds with 200', async () => { | |
| 237 | + | const page = await adminCtx.newPage(); | |
| 238 | + | try { | |
| 239 | + | await page.goto(srcReleaseUrl); | |
| 240 | + | const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href'); | |
| 241 | + | const resp = await page.request.get(`${BASE}${zipLink}`); | |
| 242 | + | expect(resp.status()).toBe(200); | |
| 243 | + | } finally { await page.close(); } | |
| 244 | + | }); | |
| 245 | + | ||
| 246 | + | // ── File upload ───────────────────────────────────────────────────────────── | |
| 247 | + | ||
| 248 | + | test('create release with attached file', async () => { | |
| 249 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 250 | + | multipart: { | |
| 251 | + | name: 'Asset release', | |
| 252 | + | create_tag: 'on', | |
| 253 | + | tag_name: 'v1.2.0', | |
| 254 | + | revision: 'main', | |
| 255 | + | files: { | |
| 256 | + | name: 'release-asset.txt', | |
| 257 | + | mimeType: 'text/plain', | |
| 258 | + | buffer: Buffer.from('binary-like content for testing\n'), | |
| 259 | + | }, | |
| 260 | + | }, | |
| 261 | + | maxRedirects: 0, | |
| 262 | + | }); | |
| 263 | + | expect(resp.status()).toBe(302); | |
| 264 | + | const location = resp.headers()['location']!; | |
| 265 | + | releaseWithAssetsUrl = `${BASE}${location}`; | |
| 266 | + | }); | |
| 267 | + | ||
| 268 | + | test('uploaded asset appears in downloads with filename and size', async () => { | |
| 269 | + | const page = await adminCtx.newPage(); | |
| 270 | + | try { | |
| 271 | + | await page.goto(releaseWithAssetsUrl); | |
| 272 | + | const names = await page.locator('.asset-name').allTextContents(); | |
| 273 | + | expect(names.some(n => n.includes('release-asset.txt'))).toBe(true); | |
| 274 | + | expect(await page.locator('.asset-size').isVisible()).toBe(true); | |
| 275 | + | } finally { await page.close(); } | |
| 276 | + | }); | |
| 277 | + | ||
| 278 | + | test('asset download responds with 200', async () => { | |
| 279 | + | const page = await adminCtx.newPage(); | |
| 280 | + | try { | |
| 281 | + | await page.goto(releaseWithAssetsUrl); | |
| 282 | + | const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href'); | |
| 283 | + | const resp = await page.request.get(`${BASE}${link}`); | |
| 284 | + | expect(resp.status()).toBe(200); | |
| 285 | + | } finally { await page.close(); } | |
| 286 | + | }); | |
| 287 | + | ||
| 288 | + | // ── Delete ────────────────────────────────────────────────────────────────── | |
| 289 | + | ||
| 290 | + | test('non-admin cannot delete a release', async () => { | |
| 291 | + | const page = await aliceCtx.newPage(); | |
| 292 | + | try { | |
| 293 | + | const idMatch = releaseUrl.match(/\/releases\/(\d+)/); | |
| 294 | + | const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, { | |
| 295 | + | maxRedirects: 0, | |
| 296 | + | }); | |
| 297 | + | expect(resp.status()).toBe(403); | |
| 298 | + | } finally { await page.close(); } | |
| 299 | + | }); | |
| 300 | + | ||
| 301 | + | test('admin can delete a release', async () => { | |
| 302 | + | const idMatch = releaseUrl.match(/\/releases\/(\d+)/); | |
| 303 | + | const resp = await adminCtx.request.post( | |
| 304 | + | `${BASE}/releases-repo/releases/${idMatch![1]}/delete`, | |
| 305 | + | { maxRedirects: 0 }, | |
| 306 | + | ); | |
| 307 | + | expect(resp.status()).toBe(302); | |
| 308 | + | // Verify it's gone from the list | |
| 309 | + | const page = await adminCtx.newPage(); | |
| 310 | + | try { | |
| 311 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 312 | + | const titles = await page.locator('.release-item-title').allTextContents(); | |
| 313 | + | expect(titles.some(t => t.includes('First release'))).toBe(false); | |
| 314 | + | } finally { await page.close(); } | |
| 315 | + | }); | |
| 316 | + | ||
| 317 | + | // ── Pagination ────────────────────────────────────────────────────────────── | |
| 318 | + | ||
| 319 | + | test('release list shows at most 20 per page', async () => { | |
| 320 | + | // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total | |
| 321 | + | // regardless of which browser-based tests above succeeded | |
| 322 | + | for (let i = 1; i <= 25; i++) { | |
| 323 | + | await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 324 | + | multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' }, | |
| 325 | + | maxRedirects: 0, | |
| 326 | + | }).catch(() => {}); | |
| 327 | + | } | |
| 328 | + | const page = await adminCtx.newPage(); | |
| 329 | + | try { | |
| 330 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 331 | + | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 332 | + | } finally { await page.close(); } | |
| 333 | + | }); | |
| 334 | + | ||
| 335 | + | test('pagination nav appears with more than 20 releases', async () => { | |
| 336 | + | const page = await adminCtx.newPage(); | |
| 337 | + | try { | |
| 338 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 339 | + | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 340 | + | } finally { await page.close(); } | |
| 341 | + | }); | |
| 342 | + | ||
| 343 | + | test('release list page 2 shows remaining releases', async () => { | |
| 344 | + | const page = await adminCtx.newPage(); | |
| 345 | + | try { | |
| 346 | + | await page.goto(`${BASE}/releases-repo/releases?page=2`); | |
| 347 | + | const count = await page.locator('.issue-item').count(); | |
| 348 | + | expect(count).toBeGreaterThan(0); | |
| 349 | + | expect(count).toBeLessThanOrEqual(20); | |
| 350 | + | } finally { await page.close(); } | |
| 351 | + | }); | |
| 352 | + | ||
| 353 | + | test('source archive tar.zst appears in downloads', async () => { | |
| 354 | + | const page = await adminCtx.newPage(); | |
| 355 | + | try { | |
| 356 | + | await page.goto(srcReleaseUrl); | |
| 357 | + | const assetNames = await page.locator('.asset-name').allTextContents(); | |
| 358 | + | expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true); | |
| 359 | + | } finally { await page.close(); } | |
| 360 | + | }); | |
| 361 | + | }); | |
Atests/e2e.repos.test.ts
| @@ -0,0 +1,343 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { | |
| 5 | + | BASE, | |
| 6 | + | ADMIN_PASS, | |
| 7 | + | setupTestEnv, | |
| 8 | + | spawnServer, | |
| 9 | + | killServer, | |
| 10 | + | login, | |
| 11 | + | seedRepo, | |
| 12 | + | } from './helpers.ts'; | |
| 13 | + | ||
| 14 | + | let browser: Browser; | |
| 15 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 16 | + | ||
| 17 | + | beforeAll(async () => { | |
| 18 | + | await setupTestEnv(); | |
| 19 | + | server = await spawnServer(); | |
| 20 | + | browser = await chromium.launch(); | |
| 21 | + | ||
| 22 | + | // Register alice | |
| 23 | + | const ctx = await browser.newContext(); | |
| 24 | + | const page = await ctx.newPage(); | |
| 25 | + | try { | |
| 26 | + | await page.goto(`${BASE}/register`); | |
| 27 | + | await page.fill('[name=username]', 'alice'); | |
| 28 | + | await page.fill('[name=password]', 'password123'); | |
| 29 | + | await page.fill('[name=password2]', 'password123'); | |
| 30 | + | await page.click('button[type=submit]'); | |
| 31 | + | await page.waitForURL(BASE + '/'); | |
| 32 | + | } finally { await ctx.close(); } | |
| 33 | + | }); | |
| 34 | + | ||
| 35 | + | afterAll(async () => { | |
| 36 | + | await browser.close(); | |
| 37 | + | await killServer(server); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 41 | + | const ctx = await browser.newContext(); | |
| 42 | + | const page = await ctx.newPage(); | |
| 43 | + | await login(page, username, password); | |
| 44 | + | await page.close(); | |
| 45 | + | return ctx; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ─── Repos ──────────────────────────────────────────────────────────────────── | |
| 49 | + | ||
| 50 | + | describe('repos', () => { | |
| 51 | + | // Shared admin context — cookies persist across tests in this block. | |
| 52 | + | let adminCtx: BrowserContext; | |
| 53 | + | // Alice's context, created after alice is registered in auth tests. | |
| 54 | + | let aliceCtx: BrowserContext; | |
| 55 | + | // Set after 'commit log' test; used by all commit-detail tests below. | |
| 56 | + | let commitUrl: string; | |
| 57 | + | ||
| 58 | + | beforeAll(async () => { | |
| 59 | + | adminCtx = await loggedInContext(); | |
| 60 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | afterAll(async () => { | |
| 64 | + | await adminCtx.close(); | |
| 65 | + | await aliceCtx.close(); | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | test('non-admin gets 403 on /new', async () => { | |
| 69 | + | const page = await aliceCtx.newPage(); | |
| 70 | + | try { | |
| 71 | + | const resp = await page.request.get(`${BASE}/new`); | |
| 72 | + | expect(resp.status()).toBe(403); | |
| 73 | + | } finally { await page.close(); } | |
| 74 | + | }); | |
| 75 | + | ||
| 76 | + | test('create repository', async () => { | |
| 77 | + | const page = await adminCtx.newPage(); | |
| 78 | + | try { | |
| 79 | + | await page.goto(`${BASE}/new`); | |
| 80 | + | await page.fill('[name=name]', 'my-repo'); | |
| 81 | + | await page.fill('[name=description]', 'A test repo'); | |
| 82 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 83 | + | await page.waitForURL(`${BASE}/my-repo`); | |
| 84 | + | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 85 | + | } finally { await page.close(); } | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test('repository appears in list', async () => { | |
| 89 | + | const page = await adminCtx.newPage(); | |
| 90 | + | try { | |
| 91 | + | await page.goto(BASE); | |
| 92 | + | expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo'); | |
| 93 | + | } finally { await page.close(); } | |
| 94 | + | }); | |
| 95 | + | ||
| 96 | + | test('search finds matching repo', async () => { | |
| 97 | + | const page = await adminCtx.newPage(); | |
| 98 | + | try { | |
| 99 | + | await page.goto(BASE); | |
| 100 | + | await page.fill('[name=q]', 'my-repo'); | |
| 101 | + | await page.click('.search-form button[type=submit]'); | |
| 102 | + | expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo'); | |
| 103 | + | } finally { await page.close(); } | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test('search returns empty for unknown term', async () => { | |
| 107 | + | const page = await adminCtx.newPage(); | |
| 108 | + | try { | |
| 109 | + | await page.goto(BASE); | |
| 110 | + | await page.fill('[name=q]', 'zzz-nothing-here'); | |
| 111 | + | await page.click('.search-form button[type=submit]'); | |
| 112 | + | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 113 | + | } finally { await page.close(); } | |
| 114 | + | }); | |
| 115 | + | ||
| 116 | + | test('browse file tree after seeding content', async () => { | |
| 117 | + | await seedRepo('my-repo'); | |
| 118 | + | const page = await adminCtx.newPage(); | |
| 119 | + | try { | |
| 120 | + | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 121 | + | const files = await page.locator('.file-name a').allTextContents(); | |
| 122 | + | expect(files).toContain('README.md'); | |
| 123 | + | expect(files).toContain('index.js'); | |
| 124 | + | } finally { await page.close(); } | |
| 125 | + | }); | |
| 126 | + | ||
| 127 | + | test('view file blob with syntax highlighting', async () => { | |
| 128 | + | const page = await adminCtx.newPage(); | |
| 129 | + | try { | |
| 130 | + | await page.goto(`${BASE}/my-repo/blob/main/index.js`); | |
| 131 | + | expect(await page.locator('.file-blob-name').textContent()).toBe('index.js'); | |
| 132 | + | expect(await page.locator('.file-blob-body').isVisible()).toBe(true); | |
| 133 | + | } finally { await page.close(); } | |
| 134 | + | }); | |
| 135 | + | ||
| 136 | + | test('raw file download responds 200', async () => { | |
| 137 | + | const page = await adminCtx.newPage(); | |
| 138 | + | try { | |
| 139 | + | const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`); | |
| 140 | + | expect(resp.status()).toBe(200); | |
| 141 | + | expect(resp.headers()['content-disposition']).toContain('README.md'); | |
| 142 | + | } finally { await page.close(); } | |
| 143 | + | }); | |
| 144 | + | ||
| 145 | + | test('commit log shows initial commit', async () => { | |
| 146 | + | const page = await adminCtx.newPage(); | |
| 147 | + | try { | |
| 148 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 149 | + | const subjects = await page.locator('.commit-subject').allTextContents(); | |
| 150 | + | expect(subjects.some(s => s.includes('Initial commit'))).toBe(true); | |
| 151 | + | // Navigate to the commit page and capture the URL for subsequent tests | |
| 152 | + | await page.locator('.commit-hash').first().click(); | |
| 153 | + | await page.waitForURL(/\/my-repo\/commit\//); | |
| 154 | + | commitUrl = page.url(); | |
| 155 | + | } finally { await page.close(); } | |
| 156 | + | }); | |
| 157 | + | ||
| 158 | + | test('commit detail shows metadata card', async () => { | |
| 159 | + | const page = await adminCtx.newPage(); | |
| 160 | + | try { | |
| 161 | + | await page.goto(commitUrl); | |
| 162 | + | expect(await page.locator('.commit-card').isVisible()).toBe(true); | |
| 163 | + | expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit'); | |
| 164 | + | // Author, date and SHA rows are all present | |
| 165 | + | const metaText = await page.locator('.commit-card-meta').textContent(); | |
| 166 | + | expect(metaText).toContain('Test'); // author name set by seedRepo | |
| 167 | + | expect(metaText).toContain('Author'); // label (CSS uppercases visually) | |
| 168 | + | expect(metaText).toContain('Date'); | |
| 169 | + | expect(metaText).toContain('Commit'); | |
| 170 | + | } finally { await page.close(); } | |
| 171 | + | }); | |
| 172 | + | ||
| 173 | + | test('commit detail full SHA is shown', async () => { | |
| 174 | + | const page = await adminCtx.newPage(); | |
| 175 | + | try { | |
| 176 | + | await page.goto(commitUrl); | |
| 177 | + | const sha = commitUrl.split('/commit/')[1] ?? null; | |
| 178 | + | expect(await page.locator('.commit-sha-full').textContent()).toBe(sha); | |
| 179 | + | } finally { await page.close(); } | |
| 180 | + | }); | |
| 181 | + | ||
| 182 | + | test('commit detail shows file nav sidebar', async () => { | |
| 183 | + | const page = await adminCtx.newPage(); | |
| 184 | + | try { | |
| 185 | + | await page.goto(commitUrl); | |
| 186 | + | expect(await page.locator('.file-nav-details').isVisible()).toBe(true); | |
| 187 | + | const navItems = await page.locator('.file-nav-item').allTextContents(); | |
| 188 | + | // seedRepo adds README.md and index.js | |
| 189 | + | expect(navItems.some(t => t.includes('README.md'))).toBe(true); | |
| 190 | + | expect(navItems.some(t => t.includes('index.js'))).toBe(true); | |
| 191 | + | } finally { await page.close(); } | |
| 192 | + | }); | |
| 193 | + | ||
| 194 | + | test('commit detail file nav items are anchor links to diff sections', async () => { | |
| 195 | + | const page = await adminCtx.newPage(); | |
| 196 | + | try { | |
| 197 | + | await page.goto(commitUrl); | |
| 198 | + | const hrefs = await page.locator('.file-nav-item').evaluateAll( | |
| 199 | + | els => els.map(el => el.getAttribute('href') ?? ''), | |
| 200 | + | ); | |
| 201 | + | expect(hrefs.every(h => h.startsWith('#'))).toBe(true); | |
| 202 | + | } finally { await page.close(); } | |
| 203 | + | }); | |
| 204 | + | ||
| 205 | + | test('commit detail shows diff table with added lines', async () => { | |
| 206 | + | const page = await adminCtx.newPage(); | |
| 207 | + | try { | |
| 208 | + | await page.goto(commitUrl); | |
| 209 | + | // Initial commit only adds lines | |
| 210 | + | expect(await page.locator('.diff-table').first().isVisible()).toBe(true); | |
| 211 | + | expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0); | |
| 212 | + | expect(await page.locator('.diff-row-del').count()).toBe(0); | |
| 213 | + | } finally { await page.close(); } | |
| 214 | + | }); | |
| 215 | + | ||
| 216 | + | test('commit detail diff table has line numbers', async () => { | |
| 217 | + | const page = await adminCtx.newPage(); | |
| 218 | + | try { | |
| 219 | + | await page.goto(commitUrl); | |
| 220 | + | // New-side line numbers (column 2) on add rows start at 1 | |
| 221 | + | const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent(); | |
| 222 | + | expect(firstNewLn?.trim()).toBe('1'); | |
| 223 | + | } finally { await page.close(); } | |
| 224 | + | }); | |
| 225 | + | ||
| 226 | + | test('commit detail shows added stats on file header', async () => { | |
| 227 | + | const page = await adminCtx.newPage(); | |
| 228 | + | try { | |
| 229 | + | await page.goto(commitUrl); | |
| 230 | + | const addStats = await page.locator('.diff-stat-add').allTextContents(); | |
| 231 | + | expect(addStats.length).toBeGreaterThan(0); | |
| 232 | + | expect(addStats.every(s => s.startsWith('+'))).toBe(true); | |
| 233 | + | } finally { await page.close(); } | |
| 234 | + | }); | |
| 235 | + | ||
| 236 | + | test('commit detail view-at-sha button links to blob at that commit', async () => { | |
| 237 | + | const page = await adminCtx.newPage(); | |
| 238 | + | try { | |
| 239 | + | await page.goto(commitUrl); | |
| 240 | + | const sha = commitUrl.split('/commit/')[1]; | |
| 241 | + | const btn = page.locator('.btn-xs').first(); | |
| 242 | + | const href = await btn.getAttribute('href'); | |
| 243 | + | expect(href).toContain(`/blob/${sha}/`); | |
| 244 | + | } finally { await page.close(); } | |
| 245 | + | }); | |
| 246 | + | ||
| 247 | + | test('commit detail view-at-branch button links to blob at default branch', async () => { | |
| 248 | + | const page = await adminCtx.newPage(); | |
| 249 | + | try { | |
| 250 | + | await page.goto(commitUrl); | |
| 251 | + | const btns = await page.locator('.btn-xs').allTextContents(); | |
| 252 | + | expect(btns.some(t => t.includes('@ main'))).toBe(true); | |
| 253 | + | const branchBtns = await page.locator('.btn-xs').evaluateAll( | |
| 254 | + | els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''), | |
| 255 | + | ); | |
| 256 | + | expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true); | |
| 257 | + | } finally { await page.close(); } | |
| 258 | + | }); | |
| 259 | + | ||
| 260 | + | test('commit detail file diff can be collapsed', async () => { | |
| 261 | + | const page = await adminCtx.newPage(); | |
| 262 | + | try { | |
| 263 | + | await page.goto(commitUrl); | |
| 264 | + | // File body is visible when details is open | |
| 265 | + | const diffFile = page.locator('.diff-file').first(); | |
| 266 | + | expect(await diffFile.getAttribute('open')).not.toBeNull(); | |
| 267 | + | // Click the summary to collapse | |
| 268 | + | await diffFile.locator('.diff-file-header').click(); | |
| 269 | + | expect(await diffFile.getAttribute('open')).toBeNull(); | |
| 270 | + | } finally { await page.close(); } | |
| 271 | + | }); | |
| 272 | + | ||
| 273 | + | test('commit detail file nav sidebar can be collapsed', async () => { | |
| 274 | + | const page = await adminCtx.newPage(); | |
| 275 | + | try { | |
| 276 | + | await page.goto(commitUrl); | |
| 277 | + | const nav = page.locator('.file-nav-details'); | |
| 278 | + | expect(await nav.getAttribute('open')).not.toBeNull(); | |
| 279 | + | await nav.locator('.file-nav-toggle').click(); | |
| 280 | + | expect(await nav.getAttribute('open')).toBeNull(); | |
| 281 | + | } finally { await page.close(); } | |
| 282 | + | }); | |
| 283 | + | ||
| 284 | + | test('readme renders on repo home', async () => { | |
| 285 | + | const page = await adminCtx.newPage(); | |
| 286 | + | try { | |
| 287 | + | await page.goto(`${BASE}/my-repo`); | |
| 288 | + | expect(await page.locator('.readme-header').isVisible()).toBe(true); | |
| 289 | + | expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo'); | |
| 290 | + | } finally { await page.close(); } | |
| 291 | + | }); | |
| 292 | + | ||
| 293 | + | test('private repo hidden from other users', async () => { | |
| 294 | + | // Make private | |
| 295 | + | const adminPage = await adminCtx.newPage(); | |
| 296 | + | try { | |
| 297 | + | await adminPage.goto(`${BASE}/my-repo/settings`); | |
| 298 | + | await adminPage.check('[name=is_private]'); | |
| 299 | + | await adminPage.click('form[action$="/settings"] button[type=submit]'); | |
| 300 | + | expect(await adminPage.locator('.form-success').isVisible()).toBe(true); | |
| 301 | + | } finally { await adminPage.close(); } | |
| 302 | + | ||
| 303 | + | // Alice should get 404 | |
| 304 | + | const alicePage = await aliceCtx.newPage(); | |
| 305 | + | try { | |
| 306 | + | const resp = await alicePage.request.get(`${BASE}/my-repo`); | |
| 307 | + | expect(resp.status()).toBe(404); | |
| 308 | + | await alicePage.goto(BASE); | |
| 309 | + | expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo'); | |
| 310 | + | } finally { await alicePage.close(); } | |
| 311 | + | ||
| 312 | + | // Restore to public | |
| 313 | + | const adminPage2 = await adminCtx.newPage(); | |
| 314 | + | try { | |
| 315 | + | await adminPage2.goto(`${BASE}/my-repo/settings`); | |
| 316 | + | await adminPage2.uncheck('[name=is_private]'); | |
| 317 | + | await adminPage2.click('form[action$="/settings"] button[type=submit]'); | |
| 318 | + | } finally { await adminPage2.close(); } | |
| 319 | + | }); | |
| 320 | + | ||
| 321 | + | test('settings tab visible for admin, hidden for others', async () => { | |
| 322 | + | const adminPage = await adminCtx.newPage(); | |
| 323 | + | try { | |
| 324 | + | await adminPage.goto(`${BASE}/my-repo`); | |
| 325 | + | expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true); | |
| 326 | + | } finally { await adminPage.close(); } | |
| 327 | + | ||
| 328 | + | const alicePage = await aliceCtx.newPage(); | |
| 329 | + | try { | |
| 330 | + | await alicePage.goto(`${BASE}/my-repo`); | |
| 331 | + | expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0); | |
| 332 | + | } finally { await alicePage.close(); } | |
| 333 | + | }); | |
| 334 | + | ||
| 335 | + | test('create repository with invalid name shows error', async () => { | |
| 336 | + | const resp = await adminCtx.request.post(`${BASE}/new`, { | |
| 337 | + | form: { name: 'has spaces!', description: '', default_branch: 'main' }, | |
| 338 | + | maxRedirects: 0, | |
| 339 | + | }); | |
| 340 | + | expect(resp.status()).toBe(200); | |
| 341 | + | expect(await resp.text()).toContain('Invalid repository name'); | |
| 342 | + | }); | |
| 343 | + | }); | |
Atests/e2e.settings.test.ts
| @@ -0,0 +1,396 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { rmSync, readFileSync } from 'node:fs'; | |
| 5 | + | import { spawnSync as nodeSpawnSync } from 'node:child_process'; | |
| 6 | + | import { | |
| 7 | + | BASE, | |
| 8 | + | ADMIN_PASS, | |
| 9 | + | setupTestEnv, | |
| 10 | + | spawnServer, | |
| 11 | + | killServer, | |
| 12 | + | login, | |
| 13 | + | seedRepo, | |
| 14 | + | } from './helpers.ts'; | |
| 15 | + | ||
| 16 | + | let browser: Browser; | |
| 17 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 18 | + | ||
| 19 | + | beforeAll(async () => { | |
| 20 | + | await setupTestEnv(); | |
| 21 | + | server = await spawnServer(); | |
| 22 | + | browser = await chromium.launch(); | |
| 23 | + | ||
| 24 | + | // Register alice | |
| 25 | + | const regCtx = await browser.newContext(); | |
| 26 | + | const regPage = await regCtx.newPage(); | |
| 27 | + | try { | |
| 28 | + | await regPage.goto(`${BASE}/register`); | |
| 29 | + | await regPage.fill('[name=username]', 'alice'); | |
| 30 | + | await regPage.fill('[name=password]', 'password123'); | |
| 31 | + | await regPage.fill('[name=password2]', 'password123'); | |
| 32 | + | await regPage.click('button[type=submit]'); | |
| 33 | + | await regPage.waitForURL(BASE + '/'); | |
| 34 | + | } finally { await regCtx.close(); } | |
| 35 | + | ||
| 36 | + | // Create my-repo (needed for repo deletion non-admin test) | |
| 37 | + | const adminCtx = await browser.newContext(); | |
| 38 | + | const adminPage = await adminCtx.newPage(); | |
| 39 | + | try { | |
| 40 | + | await login(adminPage); | |
| 41 | + | await adminPage.goto(`${BASE}/new`); | |
| 42 | + | await adminPage.fill('[name=name]', 'my-repo'); | |
| 43 | + | await adminPage.click('form[action="/new"] button[type=submit]'); | |
| 44 | + | await adminPage.waitForURL(`${BASE}/my-repo`); | |
| 45 | + | } finally { await adminCtx.close(); } | |
| 46 | + | ||
| 47 | + | await seedRepo('my-repo'); | |
| 48 | + | }); | |
| 49 | + | ||
| 50 | + | afterAll(async () => { | |
| 51 | + | await browser.close(); | |
| 52 | + | await killServer(server); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 56 | + | const ctx = await browser.newContext(); | |
| 57 | + | const page = await ctx.newPage(); | |
| 58 | + | await login(page, username, password); | |
| 59 | + | await page.close(); | |
| 60 | + | return ctx; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // ─── Settings ───────────────────────────────────────────────────────────────── | |
| 64 | + | ||
| 65 | + | describe('settings', () => { | |
| 66 | + | let adminCtx: BrowserContext; | |
| 67 | + | let aliceCtx: BrowserContext; | |
| 68 | + | // Public key generated once in beforeAll, reused across SSH key tests | |
| 69 | + | let testPubKey: string; | |
| 70 | + | ||
| 71 | + | beforeAll(async () => { | |
| 72 | + | adminCtx = await loggedInContext(); | |
| 73 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 74 | + | ||
| 75 | + | // Generate a throwaway ed25519 key for SSH key tests. | |
| 76 | + | const keyPath = '/tmp/hf-e2e-sshkey'; | |
| 77 | + | rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true }); | |
| 78 | + | nodeSpawnSync( | |
| 79 | + | 'ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'], | |
| 80 | + | { stdio: 'ignore' }, | |
| 81 | + | ); | |
| 82 | + | testPubKey = readFileSync(`${keyPath}.pub`, 'utf-8').trim(); | |
| 83 | + | rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true }); | |
| 84 | + | }); | |
| 85 | + | ||
| 86 | + | afterAll(async () => { | |
| 87 | + | await adminCtx.close(); | |
| 88 | + | await aliceCtx.close(); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test('settings page requires auth', async () => { | |
| 92 | + | const ctx = await browser.newContext(); | |
| 93 | + | const page = await ctx.newPage(); | |
| 94 | + | try { | |
| 95 | + | await page.goto(`${BASE}/settings`); | |
| 96 | + | expect(page.url()).toContain('/login'); | |
| 97 | + | } finally { await ctx.close(); } | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | test('settings page loads for logged-in user', async () => { | |
| 101 | + | const page = await adminCtx.newPage(); | |
| 102 | + | try { | |
| 103 | + | await page.goto(`${BASE}/settings`); | |
| 104 | + | expect(await page.locator('h1.page-title').textContent()).toBe('Settings'); | |
| 105 | + | } finally { await page.close(); } | |
| 106 | + | }); | |
| 107 | + | ||
| 108 | + | // ── Password ────────────────────────────────────────────────────────────── | |
| 109 | + | ||
| 110 | + | test('password change with mismatched passwords shows error', async () => { | |
| 111 | + | const page = await aliceCtx.newPage(); | |
| 112 | + | try { | |
| 113 | + | await page.goto(`${BASE}/settings`); | |
| 114 | + | await page.fill('[name=new_password]', 'newpass123'); | |
| 115 | + | await page.fill('[name=confirm_password]', 'different456'); | |
| 116 | + | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 117 | + | await page.waitForURL(/\/settings/); | |
| 118 | + | expect(page.url()).toContain('error'); | |
| 119 | + | } finally { await page.close(); } | |
| 120 | + | }); | |
| 121 | + | ||
| 122 | + | test('password change with wrong current password shows error', async () => { | |
| 123 | + | const page = await aliceCtx.newPage(); | |
| 124 | + | try { | |
| 125 | + | await page.goto(`${BASE}/settings`); | |
| 126 | + | await page.fill('[name=current_password]', 'wrongpassword'); | |
| 127 | + | await page.fill('[name=new_password]', 'newpass123'); | |
| 128 | + | await page.fill('[name=confirm_password]', 'newpass123'); | |
| 129 | + | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 130 | + | await page.waitForURL(/\/settings/); | |
| 131 | + | expect(page.url()).toContain('error'); | |
| 132 | + | } finally { await page.close(); } | |
| 133 | + | }); | |
| 134 | + | ||
| 135 | + | test('password change too short shows error', async () => { | |
| 136 | + | const page = await aliceCtx.newPage(); | |
| 137 | + | try { | |
| 138 | + | await page.goto(`${BASE}/settings`); | |
| 139 | + | await page.fill('[name=current_password]', 'password123'); | |
| 140 | + | await page.fill('[name=new_password]', 'short'); | |
| 141 | + | await page.fill('[name=confirm_password]', 'short'); | |
| 142 | + | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 143 | + | await page.waitForURL(/\/settings/); | |
| 144 | + | expect(page.url()).toContain('error'); | |
| 145 | + | } finally { await page.close(); } | |
| 146 | + | }); | |
| 147 | + | ||
| 148 | + | // ── SSH keys ────────────────────────────────────────────────────────────── | |
| 149 | + | ||
| 150 | + | test('add SSH key with unsupported key type shows error', async () => { | |
| 151 | + | const page = await adminCtx.newPage(); | |
| 152 | + | try { | |
| 153 | + | await page.goto(`${BASE}/settings`); | |
| 154 | + | await page.fill('#ssh_key_name', 'Bad key'); | |
| 155 | + | await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test'); | |
| 156 | + | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 157 | + | await page.waitForURL(/\/settings/); | |
| 158 | + | expect(page.url()).toContain('error'); | |
| 159 | + | } finally { await page.close(); } | |
| 160 | + | }); | |
| 161 | + | ||
| 162 | + | test('add valid SSH key shows success and key appears in list', async () => { | |
| 163 | + | const page = await adminCtx.newPage(); | |
| 164 | + | try { | |
| 165 | + | await page.goto(`${BASE}/settings`); | |
| 166 | + | await page.fill('#ssh_key_name', 'My Laptop'); | |
| 167 | + | await page.fill('#ssh_public_key', testPubKey); | |
| 168 | + | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 169 | + | await page.waitForURL(/\/settings/); | |
| 170 | + | expect(page.url()).toContain('success=ssh_key_added'); | |
| 171 | + | await page.goto(`${BASE}/settings`); | |
| 172 | + | expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop'); | |
| 173 | + | } finally { await page.close(); } | |
| 174 | + | }); | |
| 175 | + | ||
| 176 | + | test('add duplicate SSH key shows error', async () => { | |
| 177 | + | const page = await adminCtx.newPage(); | |
| 178 | + | try { | |
| 179 | + | await page.goto(`${BASE}/settings`); | |
| 180 | + | await page.fill('#ssh_key_name', 'Duplicate'); | |
| 181 | + | await page.fill('#ssh_public_key', testPubKey); | |
| 182 | + | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 183 | + | await page.waitForURL(/\/settings/); | |
| 184 | + | expect(page.url()).toContain('error'); | |
| 185 | + | } finally { await page.close(); } | |
| 186 | + | }); | |
| 187 | + | ||
| 188 | + | test('delete SSH key removes it from list', async () => { | |
| 189 | + | const page = await adminCtx.newPage(); | |
| 190 | + | try { | |
| 191 | + | await page.goto(`${BASE}/settings`); | |
| 192 | + | // Click the Remove button for the key added above | |
| 193 | + | await page.click('form[action="/settings/ssh-keys/delete"] button'); | |
| 194 | + | await page.waitForURL(/\/settings/); | |
| 195 | + | expect(page.url()).toContain('success=ssh_key_deleted'); | |
| 196 | + | await page.goto(`${BASE}/settings`); | |
| 197 | + | expect(await page.locator('.ssh-key-name').count()).toBe(0); | |
| 198 | + | } finally { await page.close(); } | |
| 199 | + | }); | |
| 200 | + | ||
| 201 | + | // ── Admin user management ──────────────────────────────────────────────── | |
| 202 | + | ||
| 203 | + | test('admin can create a new user account', async () => { | |
| 204 | + | const page = await adminCtx.newPage(); | |
| 205 | + | try { | |
| 206 | + | await page.goto(`${BASE}/settings`); | |
| 207 | + | await page.fill('#new_username', 'charlie'); | |
| 208 | + | await page.fill('#new_user_password', 'charliepw1'); | |
| 209 | + | await page.click('form[action="/admin/users"] button[type=submit]'); | |
| 210 | + | await page.waitForURL(/\/settings/); | |
| 211 | + | expect(page.url()).toContain('success=user_created'); | |
| 212 | + | } finally { await page.close(); } | |
| 213 | + | }); | |
| 214 | + | ||
| 215 | + | test('admin cannot create duplicate username', async () => { | |
| 216 | + | const page = await adminCtx.newPage(); | |
| 217 | + | try { | |
| 218 | + | await page.goto(`${BASE}/settings`); | |
| 219 | + | await page.fill('#new_username', 'charlie'); | |
| 220 | + | await page.fill('#new_user_password', 'charliepw1'); | |
| 221 | + | await page.click('form[action="/admin/users"] button[type=submit]'); | |
| 222 | + | await page.waitForURL(/\/settings/); | |
| 223 | + | expect(page.url()).toContain('error'); | |
| 224 | + | } finally { await page.close(); } | |
| 225 | + | }); | |
| 226 | + | ||
| 227 | + | test('admin cannot create user with invalid username characters', async () => { | |
| 228 | + | const resp = await adminCtx.request.post(`${BASE}/admin/users`, { | |
| 229 | + | form: { username: 'bad user!', password: 'password123' }, | |
| 230 | + | maxRedirects: 0, | |
| 231 | + | }); | |
| 232 | + | expect(resp.status()).toBe(302); | |
| 233 | + | const location = resp.headers()['location'] ?? ''; | |
| 234 | + | expect(location).toContain('error'); | |
| 235 | + | }); | |
| 236 | + | ||
| 237 | + | test('non-admin gets 403 when creating user', async () => { | |
| 238 | + | const resp = await aliceCtx.request.post(`${BASE}/admin/users`, { | |
| 239 | + | form: { username: 'hacker', password: 'password123' }, | |
| 240 | + | maxRedirects: 0, | |
| 241 | + | }); | |
| 242 | + | expect(resp.status()).toBe(403); | |
| 243 | + | }); | |
| 244 | + | ||
| 245 | + | test('admin can delete user account', async () => { | |
| 246 | + | const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { | |
| 247 | + | form: { username: 'charlie' }, | |
| 248 | + | maxRedirects: 0, | |
| 249 | + | }); | |
| 250 | + | expect(resp.status()).toBe(302); | |
| 251 | + | expect(resp.headers()['location']).toContain('success=user_deleted'); | |
| 252 | + | }); | |
| 253 | + | ||
| 254 | + | test('admin cannot delete the admin account', async () => { | |
| 255 | + | const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { | |
| 256 | + | form: { username: 'admin' }, | |
| 257 | + | maxRedirects: 0, | |
| 258 | + | }); | |
| 259 | + | expect(resp.status()).toBe(302); | |
| 260 | + | expect(resp.headers()['location']).toContain('error'); | |
| 261 | + | }); | |
| 262 | + | ||
| 263 | + | test('settings page has no git identity section', async () => { | |
| 264 | + | const page = await adminCtx.newPage(); | |
| 265 | + | try { | |
| 266 | + | await page.goto(`${BASE}/settings`); | |
| 267 | + | expect(await page.locator('text=Git Identity').count()).toBe(0); | |
| 268 | + | expect(await page.locator('[name=git_name]').count()).toBe(0); | |
| 269 | + | expect(await page.locator('[name=git_email]').count()).toBe(0); | |
| 270 | + | } finally { await page.close(); } | |
| 271 | + | }); | |
| 272 | + | ||
| 273 | + | test('git identity route no longer exists', async () => { | |
| 274 | + | const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, { | |
| 275 | + | form: { git_name: 'Test', git_email: 'test@example.com' }, | |
| 276 | + | maxRedirects: 0, | |
| 277 | + | }); | |
| 278 | + | expect(resp.status()).toBe(404); | |
| 279 | + | }); | |
| 280 | + | }); | |
| 281 | + | ||
| 282 | + | // ─── Repository deletion ────────────────────────────────────────────────────── | |
| 283 | + | ||
| 284 | + | describe('repository deletion', () => { | |
| 285 | + | let adminCtx: BrowserContext; | |
| 286 | + | ||
| 287 | + | beforeAll(async () => { | |
| 288 | + | adminCtx = await loggedInContext(); | |
| 289 | + | ||
| 290 | + | // Create a repo to delete | |
| 291 | + | const page = await adminCtx.newPage(); | |
| 292 | + | try { | |
| 293 | + | await page.goto(`${BASE}/new`); | |
| 294 | + | await page.fill('[name=name]', 'deleteme-repo'); | |
| 295 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 296 | + | await page.waitForURL(`${BASE}/deleteme-repo`); | |
| 297 | + | } finally { await page.close(); } | |
| 298 | + | }); | |
| 299 | + | ||
| 300 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 301 | + | ||
| 302 | + | test('admin can delete repository', async () => { | |
| 303 | + | const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, { | |
| 304 | + | maxRedirects: 0, | |
| 305 | + | }); | |
| 306 | + | expect(resp.status()).toBe(302); | |
| 307 | + | expect(resp.headers()['location']).toBe('/'); | |
| 308 | + | }); | |
| 309 | + | ||
| 310 | + | test('deleted repository returns 404', async () => { | |
| 311 | + | const page = await adminCtx.newPage(); | |
| 312 | + | try { | |
| 313 | + | const resp = await page.request.get(`${BASE}/deleteme-repo`); | |
| 314 | + | expect(resp.status()).toBe(404); | |
| 315 | + | } finally { await page.close(); } | |
| 316 | + | }); | |
| 317 | + | ||
| 318 | + | test('deleted repository no longer appears in list', async () => { | |
| 319 | + | const page = await adminCtx.newPage(); | |
| 320 | + | try { | |
| 321 | + | await page.goto(BASE); | |
| 322 | + | expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo'); | |
| 323 | + | } finally { await page.close(); } | |
| 324 | + | }); | |
| 325 | + | ||
| 326 | + | test('non-admin cannot delete repository', async () => { | |
| 327 | + | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 328 | + | const page = await aliceCtx.newPage(); | |
| 329 | + | try { | |
| 330 | + | const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, { | |
| 331 | + | maxRedirects: 0, | |
| 332 | + | }); | |
| 333 | + | expect(resp.status()).toBe(403); | |
| 334 | + | } finally { | |
| 335 | + | await page.close(); | |
| 336 | + | await aliceCtx.close(); | |
| 337 | + | } | |
| 338 | + | }); | |
| 339 | + | }); | |
| 340 | + | ||
| 341 | + | // ─── 404 handling ───────────────────────────────────────────────────────────── | |
| 342 | + | ||
| 343 | + | describe('404 handling', () => { | |
| 344 | + | let adminCtx: BrowserContext; | |
| 345 | + | ||
| 346 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 347 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 348 | + | ||
| 349 | + | test('non-existent repository returns 404', async () => { | |
| 350 | + | const page = await adminCtx.newPage(); | |
| 351 | + | try { | |
| 352 | + | const resp = await page.request.get(`${BASE}/no-such-repo`); | |
| 353 | + | expect(resp.status()).toBe(404); | |
| 354 | + | } finally { await page.close(); } | |
| 355 | + | }); | |
| 356 | + | ||
| 357 | + | test('non-existent issue returns 404', async () => { | |
| 358 | + | const page = await adminCtx.newPage(); | |
| 359 | + | try { | |
| 360 | + | const resp = await page.request.get(`${BASE}/my-repo/issues/99999`); | |
| 361 | + | expect(resp.status()).toBe(404); | |
| 362 | + | } finally { await page.close(); } | |
| 363 | + | }); | |
| 364 | + | ||
| 365 | + | test('non-existent commit returns 404', async () => { | |
| 366 | + | const page = await adminCtx.newPage(); | |
| 367 | + | try { | |
| 368 | + | const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`); | |
| 369 | + | expect(resp.status()).toBe(404); | |
| 370 | + | } finally { await page.close(); } | |
| 371 | + | }); | |
| 372 | + | ||
| 373 | + | test('non-existent file blob returns 404', async () => { | |
| 374 | + | const page = await adminCtx.newPage(); | |
| 375 | + | try { | |
| 376 | + | const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`); | |
| 377 | + | expect(resp.status()).toBe(404); | |
| 378 | + | } finally { await page.close(); } | |
| 379 | + | }); | |
| 380 | + | ||
| 381 | + | test('non-existent patch returns 404', async () => { | |
| 382 | + | const page = await adminCtx.newPage(); | |
| 383 | + | try { | |
| 384 | + | const resp = await page.request.get(`${BASE}/my-repo/patches/99999`); | |
| 385 | + | expect(resp.status()).toBe(404); | |
| 386 | + | } finally { await page.close(); } | |
| 387 | + | }); | |
| 388 | + | ||
| 389 | + | test('non-existent release returns 404', async () => { | |
| 390 | + | const page = await adminCtx.newPage(); | |
| 391 | + | try { | |
| 392 | + | const resp = await page.request.get(`${BASE}/my-repo/releases/99999`); | |
| 393 | + | expect(resp.status()).toBe(404); | |
| 394 | + | } finally { await page.close(); } | |
| 395 | + | }); | |
| 396 | + | }); | |
Atests/e2e.sorting.test.ts
| @@ -0,0 +1,185 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { | |
| 5 | + | BASE, | |
| 6 | + | ADMIN_PASS, | |
| 7 | + | setupTestEnv, | |
| 8 | + | spawnServer, | |
| 9 | + | killServer, | |
| 10 | + | login, | |
| 11 | + | } from './helpers.ts'; | |
| 12 | + | ||
| 13 | + | let browser: Browser; | |
| 14 | + | let server: Awaited<ReturnType<typeof spawnServer>>; | |
| 15 | + | ||
| 16 | + | beforeAll(async () => { | |
| 17 | + | await setupTestEnv(); | |
| 18 | + | server = await spawnServer(); | |
| 19 | + | browser = await chromium.launch(); | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | afterAll(async () => { | |
| 23 | + | await browser.close(); | |
| 24 | + | await killServer(server); | |
| 25 | + | }); | |
| 26 | + | ||
| 27 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 28 | + | const ctx = await browser.newContext(); | |
| 29 | + | const page = await ctx.newPage(); | |
| 30 | + | await login(page, username, password); | |
| 31 | + | await page.close(); | |
| 32 | + | return ctx; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ─── Repo sorting and pinning ───────────────────────────────────────────────── | |
| 36 | + | ||
| 37 | + | describe('repo sorting and pinning', () => { | |
| 38 | + | let adminCtx: BrowserContext; | |
| 39 | + | ||
| 40 | + | beforeAll(async () => { | |
| 41 | + | adminCtx = await loggedInContext(); | |
| 42 | + | // Create two repos with predictable names: sort-aaa (created first/older), | |
| 43 | + | // sort-zzz (created second/newer). This lets us verify both name order and | |
| 44 | + | // creation-time order independently. | |
| 45 | + | // Also create my-repo which is navigated to in one test. | |
| 46 | + | const page = await adminCtx.newPage(); | |
| 47 | + | try { | |
| 48 | + | await page.goto(`${BASE}/new`); | |
| 49 | + | await page.fill('[name=name]', 'my-repo'); | |
| 50 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 51 | + | await page.waitForURL(`${BASE}/my-repo`); | |
| 52 | + | ||
| 53 | + | await page.goto(`${BASE}/new`); | |
| 54 | + | await page.fill('[name=name]', 'sort-aaa'); | |
| 55 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 56 | + | await page.waitForURL(`${BASE}/sort-aaa`); | |
| 57 | + | ||
| 58 | + | await page.goto(`${BASE}/new`); | |
| 59 | + | await page.fill('[name=name]', 'sort-zzz'); | |
| 60 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 61 | + | await page.waitForURL(`${BASE}/sort-zzz`); | |
| 62 | + | } finally { await page.close(); } | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 66 | + | ||
| 67 | + | test('sort dropdown is visible on repo list page', async () => { | |
| 68 | + | const page = await adminCtx.newPage(); | |
| 69 | + | try { | |
| 70 | + | await page.goto(BASE); | |
| 71 | + | const options = await page.locator('.repo-sort-select option').allTextContents(); | |
| 72 | + | expect(options.some(t => t.includes('Newest'))).toBe(true); | |
| 73 | + | expect(options.some(t => t.includes('Name'))).toBe(true); | |
| 74 | + | } finally { await page.close(); } | |
| 75 | + | }); | |
| 76 | + | ||
| 77 | + | test('newest option is selected by default', async () => { | |
| 78 | + | // Use a fresh context to ensure no repo_sort cookie is set. | |
| 79 | + | const ctx = await browser.newContext(); | |
| 80 | + | const page = await ctx.newPage(); | |
| 81 | + | try { | |
| 82 | + | await login(page, 'admin', ADMIN_PASS); | |
| 83 | + | await page.goto(BASE); | |
| 84 | + | expect(await page.locator('.repo-sort-select').inputValue()).toBe('created'); | |
| 85 | + | } finally { await ctx.close(); } | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test('Go button is hidden with JS and works without JS', async () => { | |
| 89 | + | const ctx = await browser.newContext({ javaScriptEnabled: false }); | |
| 90 | + | const page = await ctx.newPage(); | |
| 91 | + | try { | |
| 92 | + | await login(page, 'admin', ADMIN_PASS); | |
| 93 | + | await page.goto(BASE); | |
| 94 | + | // Go button visible without JS | |
| 95 | + | expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true); | |
| 96 | + | // Select name sort and submit via Go button | |
| 97 | + | await page.locator('.repo-sort-select').selectOption('name'); | |
| 98 | + | await page.locator('form[action="/sort"] button[type=submit]').click(); | |
| 99 | + | await page.waitForURL(BASE + '/'); | |
| 100 | + | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 101 | + | } finally { await ctx.close(); } | |
| 102 | + | }); | |
| 103 | + | ||
| 104 | + | test('selecting name sort sets cookie and persists on next visit', async () => { | |
| 105 | + | const page = await adminCtx.newPage(); | |
| 106 | + | try { | |
| 107 | + | await page.goto(BASE); | |
| 108 | + | await Promise.all([ | |
| 109 | + | page.waitForURL(BASE + '/'), | |
| 110 | + | page.locator('.repo-sort-select').selectOption('name'), | |
| 111 | + | ]); | |
| 112 | + | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 113 | + | // Navigate away and back to confirm cookie persists | |
| 114 | + | await page.goto(`${BASE}/my-repo`); | |
| 115 | + | await page.goto(BASE); | |
| 116 | + | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 117 | + | } finally { | |
| 118 | + | // Reset cookie so subsequent tests start from the default sort. | |
| 119 | + | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 120 | + | await page.close(); | |
| 121 | + | } | |
| 122 | + | }); | |
| 123 | + | ||
| 124 | + | test('default sort shows newer repo before older repo', async () => { | |
| 125 | + | const page = await adminCtx.newPage(); | |
| 126 | + | try { | |
| 127 | + | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 128 | + | await page.goto(`${BASE}/?q=sort-`); | |
| 129 | + | const names = await page.locator('.repo-name').allTextContents(); | |
| 130 | + | expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa')); | |
| 131 | + | } finally { await page.close(); } | |
| 132 | + | }); | |
| 133 | + | ||
| 134 | + | test('name sort shows repos in alphabetical order', async () => { | |
| 135 | + | const page = await adminCtx.newPage(); | |
| 136 | + | try { | |
| 137 | + | await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]); | |
| 138 | + | await page.goto(`${BASE}/?q=sort-`); | |
| 139 | + | const names = await page.locator('.repo-name').allTextContents(); | |
| 140 | + | expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz')); | |
| 141 | + | } finally { | |
| 142 | + | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 143 | + | await page.close(); | |
| 144 | + | } | |
| 145 | + | }); | |
| 146 | + | ||
| 147 | + | test('pinning a repo shows pinned badge on list page', async () => { | |
| 148 | + | const page = await adminCtx.newPage(); | |
| 149 | + | try { | |
| 150 | + | await page.goto(`${BASE}/sort-aaa/settings`); | |
| 151 | + | await page.check('[name=is_pinned]'); | |
| 152 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 153 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 154 | + | ||
| 155 | + | await page.goto(BASE); | |
| 156 | + | const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' }); | |
| 157 | + | expect(await card.locator('.badge-pinned').isVisible()).toBe(true); | |
| 158 | + | } finally { await page.close(); } | |
| 159 | + | }); | |
| 160 | + | ||
| 161 | + | test('pinned repo appears before unpinned repos regardless of creation order', async () => { | |
| 162 | + | const page = await adminCtx.newPage(); | |
| 163 | + | try { | |
| 164 | + | // sort-aaa is pinned; default (newest) sort would normally show sort-zzz | |
| 165 | + | // first since it's newer — but pinned repos float to the top. | |
| 166 | + | await page.goto(`${BASE}/?q=sort-`); | |
| 167 | + | const names = await page.locator('.repo-name').allTextContents(); | |
| 168 | + | expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz')); | |
| 169 | + | } finally { await page.close(); } | |
| 170 | + | }); | |
| 171 | + | ||
| 172 | + | test('unpinning a repo removes the pinned badge', async () => { | |
| 173 | + | const page = await adminCtx.newPage(); | |
| 174 | + | try { | |
| 175 | + | await page.goto(`${BASE}/sort-aaa/settings`); | |
| 176 | + | await page.uncheck('[name=is_pinned]'); | |
| 177 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 178 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 179 | + | ||
| 180 | + | await page.goto(BASE); | |
| 181 | + | const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' }); | |
| 182 | + | expect(await card.locator('.badge-pinned').count()).toBe(0); | |
| 183 | + | } finally { await page.close(); } | |
| 184 | + | }); | |
| 185 | + | }); | |
Dtests/e2e.test.ts-3458
| @@ -1,3458 +0,0 @@ | |||
|---|---|---|---|
| 1 | - | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | - | import { chromium } from 'playwright'; | |
| 3 | - | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | - | import { $ } from 'bun'; | |
| 5 | - | import { existsSync, readFileSync } from 'node:fs'; | |
| 6 | - | import { | |
| 7 | - | BASE, | |
| 8 | - | DATA_DIR, | |
| 9 | - | ADMIN_PASS, | |
| 10 | - | setupTestEnv, | |
| 11 | - | spawnServer, | |
| 12 | - | waitForServer, | |
| 13 | - | login, | |
| 14 | - | logout, | |
| 15 | - | seedRepo, | |
| 16 | - | seedBranch, | |
| 17 | - | seedSubdir, | |
| 18 | - | writeTempFile, | |
| 19 | - | getHeadCommit, | |
| 20 | - | PORT, | |
| 21 | - | } from './helpers.ts'; | |
| 22 | - | ||
| 23 | - | // ─── Global setup ──────────────────────────────────────────────────────────── | |
| 24 | - | ||
| 25 | - | let browser: Browser; | |
| 26 | - | let server: ReturnType<typeof spawnServer>; | |
| 27 | - | ||
| 28 | - | beforeAll(async () => { | |
| 29 | - | await setupTestEnv(); | |
| 30 | - | server = spawnServer(); | |
| 31 | - | await waitForServer(); | |
| 32 | - | browser = await chromium.launch(); | |
| 33 | - | }); | |
| 34 | - | ||
| 35 | - | afterAll(async () => { | |
| 36 | - | await browser.close(); | |
| 37 | - | server.kill(); | |
| 38 | - | }); | |
| 39 | - | ||
| 40 | - | // Helper: create a context already logged in as a given user. | |
| 41 | - | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 42 | - | const ctx = await browser.newContext(); | |
| 43 | - | const page = await ctx.newPage(); | |
| 44 | - | await login(page, username, password); | |
| 45 | - | await page.close(); | |
| 46 | - | return ctx; | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Helper: create N issues in a repo using the server API (no browser rendering) | |
| 50 | - | async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) { | |
| 51 | - | for (let i = 1; i <= count; i++) { | |
| 52 | - | await ctx.request.fetch(`${BASE}/${repo}/issues`, { | |
| 53 | - | method: 'POST', | |
| 54 | - | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 55 | - | data: `title=Issue+number+${i}&body=`, | |
| 56 | - | maxRedirects: 0, | |
| 57 | - | }).catch(() => {}); // 302 redirect throws; that's fine | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // Helper: create N patches in a repo using the server API | |
| 62 | - | async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) { | |
| 63 | - | const VALID_PATCH = [ | |
| 64 | - | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 65 | - | 'From: Test User <test@example.com>', | |
| 66 | - | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 67 | - | 'Subject: [PATCH] Add f.txt', | |
| 68 | - | '', | |
| 69 | - | '---', | |
| 70 | - | 'diff --git a/f.txt b/f.txt', | |
| 71 | - | 'new file mode 100644', | |
| 72 | - | 'index 0000000..9daeafb', | |
| 73 | - | '--- /dev/null', | |
| 74 | - | '+++ b/f.txt', | |
| 75 | - | '@@ -0,0 +1 @@', | |
| 76 | - | '+x', | |
| 77 | - | '', | |
| 78 | - | ].join('\n'); | |
| 79 | - | ||
| 80 | - | writeTempFile('/tmp/bulk.patch', VALID_PATCH); | |
| 81 | - | for (let i = 1; i <= count; i++) { | |
| 82 | - | const page = await ctx.newPage(); | |
| 83 | - | try { | |
| 84 | - | await page.goto(`${BASE}/${repo}/patches/new`); | |
| 85 | - | await page.fill('[name=title]', `Patch number ${i}`); | |
| 86 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/bulk.patch'); | |
| 87 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 88 | - | await page.waitForURL(new RegExp(`/${repo}/patches/\\d+`)); | |
| 89 | - | } finally { await page.close(); } | |
| 90 | - | } | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // ─── Auth ───────────────────────────────────────────────────────────────────── | |
| 94 | - | ||
| 95 | - | describe('auth', () => { | |
| 96 | - | test('homepage loads', async () => { | |
| 97 | - | const ctx = await browser.newContext(); | |
| 98 | - | const page = await ctx.newPage(); | |
| 99 | - | try { | |
| 100 | - | await page.goto(BASE); | |
| 101 | - | expect(await page.title()).toContain('Hearthforge'); | |
| 102 | - | } finally { await ctx.close(); } | |
| 103 | - | }); | |
| 104 | - | ||
| 105 | - | test('wrong password shows error', async () => { | |
| 106 | - | const ctx = await browser.newContext(); | |
| 107 | - | const page = await ctx.newPage(); | |
| 108 | - | try { | |
| 109 | - | await page.goto(`${BASE}/login`); | |
| 110 | - | await page.fill('[name=username]', 'admin'); | |
| 111 | - | await page.fill('[name=password]', 'wrongpassword'); | |
| 112 | - | await page.click('button[type=submit]'); | |
| 113 | - | expect(await page.locator('.form-error').textContent()).toContain('Invalid'); | |
| 114 | - | } finally { await ctx.close(); } | |
| 115 | - | }); | |
| 116 | - | ||
| 117 | - | test('correct credentials redirect to homepage', async () => { | |
| 118 | - | const ctx = await browser.newContext(); | |
| 119 | - | const page = await ctx.newPage(); | |
| 120 | - | try { | |
| 121 | - | await login(page); | |
| 122 | - | expect(page.url()).toBe(BASE + '/'); | |
| 123 | - | expect(await page.locator('.nav-user').isVisible()).toBe(true); | |
| 124 | - | } finally { await ctx.close(); } | |
| 125 | - | }); | |
| 126 | - | ||
| 127 | - | test('register new user', async () => { | |
| 128 | - | const ctx = await browser.newContext(); | |
| 129 | - | const page = await ctx.newPage(); | |
| 130 | - | try { | |
| 131 | - | await page.goto(`${BASE}/register`); | |
| 132 | - | await page.fill('[name=username]', 'alice'); | |
| 133 | - | await page.fill('[name=password]', 'password123'); | |
| 134 | - | await page.fill('[name=password2]', 'password123'); | |
| 135 | - | await page.click('button[type=submit]'); | |
| 136 | - | await page.waitForURL(BASE + '/'); | |
| 137 | - | expect(await page.locator('.nav-user').textContent()).toBe('alice'); | |
| 138 | - | } finally { await ctx.close(); } | |
| 139 | - | }); | |
| 140 | - | ||
| 141 | - | test('register with mismatched passwords shows error', async () => { | |
| 142 | - | const ctx = await browser.newContext(); | |
| 143 | - | const page = await ctx.newPage(); | |
| 144 | - | try { | |
| 145 | - | await page.goto(`${BASE}/register`); | |
| 146 | - | await page.fill('[name=username]', 'bob'); | |
| 147 | - | await page.fill('[name=password]', 'password123'); | |
| 148 | - | await page.fill('[name=password2]', 'different456'); | |
| 149 | - | await page.click('button[type=submit]'); | |
| 150 | - | expect(await page.locator('.form-error').textContent()).toContain('match'); | |
| 151 | - | } finally { await ctx.close(); } | |
| 152 | - | }); | |
| 153 | - | ||
| 154 | - | test('register with duplicate username shows error', async () => { | |
| 155 | - | const ctx = await browser.newContext(); | |
| 156 | - | const page = await ctx.newPage(); | |
| 157 | - | try { | |
| 158 | - | await page.goto(`${BASE}/register`); | |
| 159 | - | await page.fill('[name=username]', 'alice'); // already registered above | |
| 160 | - | await page.fill('[name=password]', 'password123'); | |
| 161 | - | await page.fill('[name=password2]', 'password123'); | |
| 162 | - | await page.click('button[type=submit]'); | |
| 163 | - | expect(await page.locator('.form-error').textContent()).toContain('taken'); | |
| 164 | - | } finally { await ctx.close(); } | |
| 165 | - | }); | |
| 166 | - | ||
| 167 | - | test('logout clears session', async () => { | |
| 168 | - | const ctx = await browser.newContext(); | |
| 169 | - | const page = await ctx.newPage(); | |
| 170 | - | try { | |
| 171 | - | await login(page); | |
| 172 | - | await logout(page); | |
| 173 | - | expect(await page.locator('.nav-user').count()).toBe(0); | |
| 174 | - | expect(await page.locator('a[href="/login"]').isVisible()).toBe(true); | |
| 175 | - | } finally { await ctx.close(); } | |
| 176 | - | }); | |
| 177 | - | }); | |
| 178 | - | ||
| 179 | - | // ─── Repos ──────────────────────────────────────────────────────────────────── | |
| 180 | - | ||
| 181 | - | describe('repos', () => { | |
| 182 | - | // Shared admin context — cookies persist across tests in this block. | |
| 183 | - | let adminCtx: BrowserContext; | |
| 184 | - | // Alice's context, created after alice is registered in auth tests. | |
| 185 | - | let aliceCtx: BrowserContext; | |
| 186 | - | // Set after 'commit log' test; used by all commit-detail tests below. | |
| 187 | - | let commitUrl: string; | |
| 188 | - | ||
| 189 | - | beforeAll(async () => { | |
| 190 | - | adminCtx = await loggedInContext(); | |
| 191 | - | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 192 | - | }); | |
| 193 | - | ||
| 194 | - | afterAll(async () => { | |
| 195 | - | await adminCtx.close(); | |
| 196 | - | await aliceCtx.close(); | |
| 197 | - | }); | |
| 198 | - | ||
| 199 | - | test('non-admin gets 403 on /new', async () => { | |
| 200 | - | const page = await aliceCtx.newPage(); | |
| 201 | - | try { | |
| 202 | - | const resp = await page.request.get(`${BASE}/new`); | |
| 203 | - | expect(resp.status()).toBe(403); | |
| 204 | - | } finally { await page.close(); } | |
| 205 | - | }); | |
| 206 | - | ||
| 207 | - | test('create repository', async () => { | |
| 208 | - | const page = await adminCtx.newPage(); | |
| 209 | - | try { | |
| 210 | - | await page.goto(`${BASE}/new`); | |
| 211 | - | await page.fill('[name=name]', 'my-repo'); | |
| 212 | - | await page.fill('[name=description]', 'A test repo'); | |
| 213 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 214 | - | await page.waitForURL(`${BASE}/my-repo`); | |
| 215 | - | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 216 | - | } finally { await page.close(); } | |
| 217 | - | }); | |
| 218 | - | ||
| 219 | - | test('repository appears in list', async () => { | |
| 220 | - | const page = await adminCtx.newPage(); | |
| 221 | - | try { | |
| 222 | - | await page.goto(BASE); | |
| 223 | - | expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo'); | |
| 224 | - | } finally { await page.close(); } | |
| 225 | - | }); | |
| 226 | - | ||
| 227 | - | test('search finds matching repo', async () => { | |
| 228 | - | const page = await adminCtx.newPage(); | |
| 229 | - | try { | |
| 230 | - | await page.goto(BASE); | |
| 231 | - | await page.fill('[name=q]', 'my-repo'); | |
| 232 | - | await page.click('.search-form button[type=submit]'); | |
| 233 | - | expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo'); | |
| 234 | - | } finally { await page.close(); } | |
| 235 | - | }); | |
| 236 | - | ||
| 237 | - | test('search returns empty for unknown term', async () => { | |
| 238 | - | const page = await adminCtx.newPage(); | |
| 239 | - | try { | |
| 240 | - | await page.goto(BASE); | |
| 241 | - | await page.fill('[name=q]', 'zzz-nothing-here'); | |
| 242 | - | await page.click('.search-form button[type=submit]'); | |
| 243 | - | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 244 | - | } finally { await page.close(); } | |
| 245 | - | }); | |
| 246 | - | ||
| 247 | - | test('browse file tree after seeding content', async () => { | |
| 248 | - | await seedRepo('my-repo'); | |
| 249 | - | const page = await adminCtx.newPage(); | |
| 250 | - | try { | |
| 251 | - | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 252 | - | const files = await page.locator('.file-name a').allTextContents(); | |
| 253 | - | expect(files).toContain('README.md'); | |
| 254 | - | expect(files).toContain('index.js'); | |
| 255 | - | } finally { await page.close(); } | |
| 256 | - | }); | |
| 257 | - | ||
| 258 | - | test('view file blob with syntax highlighting', async () => { | |
| 259 | - | const page = await adminCtx.newPage(); | |
| 260 | - | try { | |
| 261 | - | await page.goto(`${BASE}/my-repo/blob/main/index.js`); | |
| 262 | - | expect(await page.locator('.file-blob-name').textContent()).toBe('index.js'); | |
| 263 | - | expect(await page.locator('.file-blob-body').isVisible()).toBe(true); | |
| 264 | - | } finally { await page.close(); } | |
| 265 | - | }); | |
| 266 | - | ||
| 267 | - | test('raw file download responds 200', async () => { | |
| 268 | - | const page = await adminCtx.newPage(); | |
| 269 | - | try { | |
| 270 | - | const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`); | |
| 271 | - | expect(resp.status()).toBe(200); | |
| 272 | - | expect(resp.headers()['content-disposition']).toContain('README.md'); | |
| 273 | - | } finally { await page.close(); } | |
| 274 | - | }); | |
| 275 | - | ||
| 276 | - | test('commit log shows initial commit', async () => { | |
| 277 | - | const page = await adminCtx.newPage(); | |
| 278 | - | try { | |
| 279 | - | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 280 | - | const subjects = await page.locator('.commit-subject').allTextContents(); | |
| 281 | - | expect(subjects.some(s => s.includes('Initial commit'))).toBe(true); | |
| 282 | - | // Navigate to the commit page and capture the URL for subsequent tests | |
| 283 | - | await page.locator('.commit-hash').first().click(); | |
| 284 | - | await page.waitForURL(/\/my-repo\/commit\//); | |
| 285 | - | commitUrl = page.url(); | |
| 286 | - | } finally { await page.close(); } | |
| 287 | - | }); | |
| 288 | - | ||
| 289 | - | test('commit detail shows metadata card', async () => { | |
| 290 | - | const page = await adminCtx.newPage(); | |
| 291 | - | try { | |
| 292 | - | await page.goto(commitUrl); | |
| 293 | - | expect(await page.locator('.commit-card').isVisible()).toBe(true); | |
| 294 | - | expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit'); | |
| 295 | - | // Author, date and SHA rows are all present | |
| 296 | - | const metaText = await page.locator('.commit-card-meta').textContent(); | |
| 297 | - | expect(metaText).toContain('Test'); // author name set by seedRepo | |
| 298 | - | expect(metaText).toContain('Author'); // label (CSS uppercases visually) | |
| 299 | - | expect(metaText).toContain('Date'); | |
| 300 | - | expect(metaText).toContain('Commit'); | |
| 301 | - | } finally { await page.close(); } | |
| 302 | - | }); | |
| 303 | - | ||
| 304 | - | test('commit detail full SHA is shown', async () => { | |
| 305 | - | const page = await adminCtx.newPage(); | |
| 306 | - | try { | |
| 307 | - | await page.goto(commitUrl); | |
| 308 | - | const sha = commitUrl.split('/commit/')[1] ?? null; | |
| 309 | - | expect(await page.locator('.commit-sha-full').textContent()).toBe(sha); | |
| 310 | - | } finally { await page.close(); } | |
| 311 | - | }); | |
| 312 | - | ||
| 313 | - | test('commit detail shows file nav sidebar', async () => { | |
| 314 | - | const page = await adminCtx.newPage(); | |
| 315 | - | try { | |
| 316 | - | await page.goto(commitUrl); | |
| 317 | - | expect(await page.locator('.file-nav-details').isVisible()).toBe(true); | |
| 318 | - | const navItems = await page.locator('.file-nav-item').allTextContents(); | |
| 319 | - | // seedRepo adds README.md and index.js | |
| 320 | - | expect(navItems.some(t => t.includes('README.md'))).toBe(true); | |
| 321 | - | expect(navItems.some(t => t.includes('index.js'))).toBe(true); | |
| 322 | - | } finally { await page.close(); } | |
| 323 | - | }); | |
| 324 | - | ||
| 325 | - | test('commit detail file nav items are anchor links to diff sections', async () => { | |
| 326 | - | const page = await adminCtx.newPage(); | |
| 327 | - | try { | |
| 328 | - | await page.goto(commitUrl); | |
| 329 | - | const hrefs = await page.locator('.file-nav-item').evaluateAll( | |
| 330 | - | els => els.map(el => el.getAttribute('href') ?? ''), | |
| 331 | - | ); | |
| 332 | - | expect(hrefs.every(h => h.startsWith('#'))).toBe(true); | |
| 333 | - | } finally { await page.close(); } | |
| 334 | - | }); | |
| 335 | - | ||
| 336 | - | test('commit detail shows diff table with added lines', async () => { | |
| 337 | - | const page = await adminCtx.newPage(); | |
| 338 | - | try { | |
| 339 | - | await page.goto(commitUrl); | |
| 340 | - | // Initial commit only adds lines | |
| 341 | - | expect(await page.locator('.diff-table').first().isVisible()).toBe(true); | |
| 342 | - | expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0); | |
| 343 | - | expect(await page.locator('.diff-row-del').count()).toBe(0); | |
| 344 | - | } finally { await page.close(); } | |
| 345 | - | }); | |
| 346 | - | ||
| 347 | - | test('commit detail diff table has line numbers', async () => { | |
| 348 | - | const page = await adminCtx.newPage(); | |
| 349 | - | try { | |
| 350 | - | await page.goto(commitUrl); | |
| 351 | - | // New-side line numbers (column 2) on add rows start at 1 | |
| 352 | - | const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent(); | |
| 353 | - | expect(firstNewLn?.trim()).toBe('1'); | |
| 354 | - | } finally { await page.close(); } | |
| 355 | - | }); | |
| 356 | - | ||
| 357 | - | test('commit detail shows added stats on file header', async () => { | |
| 358 | - | const page = await adminCtx.newPage(); | |
| 359 | - | try { | |
| 360 | - | await page.goto(commitUrl); | |
| 361 | - | const addStats = await page.locator('.diff-stat-add').allTextContents(); | |
| 362 | - | expect(addStats.length).toBeGreaterThan(0); | |
| 363 | - | expect(addStats.every(s => s.startsWith('+'))).toBe(true); | |
| 364 | - | } finally { await page.close(); } | |
| 365 | - | }); | |
| 366 | - | ||
| 367 | - | test('commit detail view-at-sha button links to blob at that commit', async () => { | |
| 368 | - | const page = await adminCtx.newPage(); | |
| 369 | - | try { | |
| 370 | - | await page.goto(commitUrl); | |
| 371 | - | const sha = commitUrl.split('/commit/')[1]; | |
| 372 | - | const btn = page.locator('.btn-xs').first(); | |
| 373 | - | const href = await btn.getAttribute('href'); | |
| 374 | - | expect(href).toContain(`/blob/${sha}/`); | |
| 375 | - | } finally { await page.close(); } | |
| 376 | - | }); | |
| 377 | - | ||
| 378 | - | test('commit detail view-at-branch button links to blob at default branch', async () => { | |
| 379 | - | const page = await adminCtx.newPage(); | |
| 380 | - | try { | |
| 381 | - | await page.goto(commitUrl); | |
| 382 | - | const btns = await page.locator('.btn-xs').allTextContents(); | |
| 383 | - | expect(btns.some(t => t.includes('@ main'))).toBe(true); | |
| 384 | - | const branchBtns = await page.locator('.btn-xs').evaluateAll( | |
| 385 | - | els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''), | |
| 386 | - | ); | |
| 387 | - | expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true); | |
| 388 | - | } finally { await page.close(); } | |
| 389 | - | }); | |
| 390 | - | ||
| 391 | - | test('commit detail file diff can be collapsed', async () => { | |
| 392 | - | const page = await adminCtx.newPage(); | |
| 393 | - | try { | |
| 394 | - | await page.goto(commitUrl); | |
| 395 | - | // File body is visible when details is open | |
| 396 | - | const diffFile = page.locator('.diff-file').first(); | |
| 397 | - | expect(await diffFile.getAttribute('open')).not.toBeNull(); | |
| 398 | - | // Click the summary to collapse | |
| 399 | - | await diffFile.locator('.diff-file-header').click(); | |
| 400 | - | expect(await diffFile.getAttribute('open')).toBeNull(); | |
| 401 | - | } finally { await page.close(); } | |
| 402 | - | }); | |
| 403 | - | ||
| 404 | - | test('commit detail file nav sidebar can be collapsed', async () => { | |
| 405 | - | const page = await adminCtx.newPage(); | |
| 406 | - | try { | |
| 407 | - | await page.goto(commitUrl); | |
| 408 | - | const nav = page.locator('.file-nav-details'); | |
| 409 | - | expect(await nav.getAttribute('open')).not.toBeNull(); | |
| 410 | - | await nav.locator('.file-nav-toggle').click(); | |
| 411 | - | expect(await nav.getAttribute('open')).toBeNull(); | |
| 412 | - | } finally { await page.close(); } | |
| 413 | - | }); | |
| 414 | - | ||
| 415 | - | test('readme renders on repo home', async () => { | |
| 416 | - | const page = await adminCtx.newPage(); | |
| 417 | - | try { | |
| 418 | - | await page.goto(`${BASE}/my-repo`); | |
| 419 | - | expect(await page.locator('.readme-header').isVisible()).toBe(true); | |
| 420 | - | expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo'); | |
| 421 | - | } finally { await page.close(); } | |
| 422 | - | }); | |
| 423 | - | ||
| 424 | - | test('private repo hidden from other users', async () => { | |
| 425 | - | // Make private | |
| 426 | - | const adminPage = await adminCtx.newPage(); | |
| 427 | - | try { | |
| 428 | - | await adminPage.goto(`${BASE}/my-repo/settings`); | |
| 429 | - | await adminPage.check('[name=is_private]'); | |
| 430 | - | await adminPage.click('form[action$="/settings"] button[type=submit]'); | |
| 431 | - | expect(await adminPage.locator('.form-success').isVisible()).toBe(true); | |
| 432 | - | } finally { await adminPage.close(); } | |
| 433 | - | ||
| 434 | - | // Alice should get 404 | |
| 435 | - | const alicePage = await aliceCtx.newPage(); | |
| 436 | - | try { | |
| 437 | - | const resp = await alicePage.request.get(`${BASE}/my-repo`); | |
| 438 | - | expect(resp.status()).toBe(404); | |
| 439 | - | await alicePage.goto(BASE); | |
| 440 | - | expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo'); | |
| 441 | - | } finally { await alicePage.close(); } | |
| 442 | - | ||
| 443 | - | // Restore to public | |
| 444 | - | const adminPage2 = await adminCtx.newPage(); | |
| 445 | - | try { | |
| 446 | - | await adminPage2.goto(`${BASE}/my-repo/settings`); | |
| 447 | - | await adminPage2.uncheck('[name=is_private]'); | |
| 448 | - | await adminPage2.click('form[action$="/settings"] button[type=submit]'); | |
| 449 | - | } finally { await adminPage2.close(); } | |
| 450 | - | }); | |
| 451 | - | ||
| 452 | - | test('settings tab visible for admin, hidden for others', async () => { | |
| 453 | - | const adminPage = await adminCtx.newPage(); | |
| 454 | - | try { | |
| 455 | - | await adminPage.goto(`${BASE}/my-repo`); | |
| 456 | - | expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true); | |
| 457 | - | } finally { await adminPage.close(); } | |
| 458 | - | ||
| 459 | - | const alicePage = await aliceCtx.newPage(); | |
| 460 | - | try { | |
| 461 | - | await alicePage.goto(`${BASE}/my-repo`); | |
| 462 | - | expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0); | |
| 463 | - | } finally { await alicePage.close(); } | |
| 464 | - | }); | |
| 465 | - | ||
| 466 | - | test('create repository with invalid name shows error', async () => { | |
| 467 | - | const resp = await adminCtx.request.post(`${BASE}/new`, { | |
| 468 | - | form: { name: 'has spaces!', description: '', default_branch: 'main' }, | |
| 469 | - | maxRedirects: 0, | |
| 470 | - | }); | |
| 471 | - | expect(resp.status()).toBe(200); | |
| 472 | - | expect(await resp.text()).toContain('Invalid repository name'); | |
| 473 | - | }); | |
| 474 | - | }); | |
| 475 | - | ||
| 476 | - | // ─── Issues ─────────────────────────────────────────────────────────────────── | |
| 477 | - | ||
| 478 | - | describe('issues', () => { | |
| 479 | - | let adminCtx: BrowserContext; | |
| 480 | - | let issueUrl: string; | |
| 481 | - | let completedIssueUrl: string; | |
| 482 | - | ||
| 483 | - | beforeAll(async () => { | |
| 484 | - | adminCtx = await loggedInContext(); | |
| 485 | - | }); | |
| 486 | - | ||
| 487 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 488 | - | ||
| 489 | - | test('create issue', async () => { | |
| 490 | - | const page = await adminCtx.newPage(); | |
| 491 | - | try { | |
| 492 | - | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 493 | - | await page.fill('[name=title]', 'First issue'); | |
| 494 | - | await page.fill('[name=body]', 'Body with **markdown**.'); | |
| 495 | - | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 496 | - | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 497 | - | issueUrl = page.url(); | |
| 498 | - | expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue'); | |
| 499 | - | } finally { await page.close(); } | |
| 500 | - | }); | |
| 501 | - | ||
| 502 | - | test('issue body renders markdown', async () => { | |
| 503 | - | const page = await adminCtx.newPage(); | |
| 504 | - | try { | |
| 505 | - | await page.goto(issueUrl); | |
| 506 | - | expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>'); | |
| 507 | - | } finally { await page.close(); } | |
| 508 | - | }); | |
| 509 | - | ||
| 510 | - | test('issue appears in open list', async () => { | |
| 511 | - | const page = await adminCtx.newPage(); | |
| 512 | - | try { | |
| 513 | - | await page.goto(`${BASE}/my-repo/issues`); | |
| 514 | - | const titles = await page.locator('.issue-title').allTextContents(); | |
| 515 | - | expect(titles.some(t => t.includes('First issue'))).toBe(true); | |
| 516 | - | } finally { await page.close(); } | |
| 517 | - | }); | |
| 518 | - | ||
| 519 | - | test('unauthenticated user is redirected to login from new issue form', async () => { | |
| 520 | - | const ctx = await browser.newContext(); | |
| 521 | - | const page = await ctx.newPage(); | |
| 522 | - | try { | |
| 523 | - | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 524 | - | expect(page.url()).toContain('/login'); | |
| 525 | - | } finally { await ctx.close(); } | |
| 526 | - | }); | |
| 527 | - | ||
| 528 | - | test('add comment', async () => { | |
| 529 | - | const page = await adminCtx.newPage(); | |
| 530 | - | try { | |
| 531 | - | await page.goto(issueUrl); | |
| 532 | - | const beforeCount = await page.locator('.timeline-item').count(); | |
| 533 | - | await page.fill('textarea[name=body]', 'A follow-up comment.'); | |
| 534 | - | await page.click('form[action*="/comments"] button[type=submit]'); | |
| 535 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 536 | - | expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount); | |
| 537 | - | } finally { await page.close(); } | |
| 538 | - | }); | |
| 539 | - | ||
| 540 | - | test('react to issue', async () => { | |
| 541 | - | const page = await adminCtx.newPage(); | |
| 542 | - | try { | |
| 543 | - | await page.goto(issueUrl); | |
| 544 | - | await page.locator('.reaction-picker').first().click(); | |
| 545 | - | await page.locator('.reaction-picker-btn').first().click(); | |
| 546 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 547 | - | expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0); | |
| 548 | - | } finally { await page.close(); } | |
| 549 | - | }); | |
| 550 | - | ||
| 551 | - | test('close issue changes status badge', async () => { | |
| 552 | - | const page = await adminCtx.newPage(); | |
| 553 | - | try { | |
| 554 | - | await page.goto(issueUrl); | |
| 555 | - | await page.click('form[action*="/close"] button'); | |
| 556 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 557 | - | expect(await page.locator('.issue-badge').textContent()).toBe('closed'); | |
| 558 | - | } finally { await page.close(); } | |
| 559 | - | }); | |
| 560 | - | ||
| 561 | - | test('closed issue appears in closed list', async () => { | |
| 562 | - | const page = await adminCtx.newPage(); | |
| 563 | - | try { | |
| 564 | - | await page.goto(`${BASE}/my-repo/issues?status=closed`); | |
| 565 | - | const titles = await page.locator('.issue-title').allTextContents(); | |
| 566 | - | expect(titles.some(t => t.includes('First issue'))).toBe(true); | |
| 567 | - | } finally { await page.close(); } | |
| 568 | - | }); | |
| 569 | - | ||
| 570 | - | test('reopen issue', async () => { | |
| 571 | - | const page = await adminCtx.newPage(); | |
| 572 | - | try { | |
| 573 | - | await page.goto(issueUrl); | |
| 574 | - | await page.click('.issue-detail-meta-actions button'); | |
| 575 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 576 | - | expect(await page.locator('.issue-badge').textContent()).toBe('open'); | |
| 577 | - | } finally { await page.close(); } | |
| 578 | - | }); | |
| 579 | - | ||
| 580 | - | test('completed button marks issue as completed', async () => { | |
| 581 | - | const page = await adminCtx.newPage(); | |
| 582 | - | try { | |
| 583 | - | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 584 | - | await page.fill('[name=title]', 'To be completed'); | |
| 585 | - | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 586 | - | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 587 | - | completedIssueUrl = page.url(); | |
| 588 | - | await page.click('form[action*="/complete"] button'); | |
| 589 | - | await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, ''))); | |
| 590 | - | expect(await page.locator('.issue-badge').textContent()).toBe('completed'); | |
| 591 | - | } finally { await page.close(); } | |
| 592 | - | }); | |
| 593 | - | ||
| 594 | - | test('completed issue appears in completed list', async () => { | |
| 595 | - | const page = await adminCtx.newPage(); | |
| 596 | - | try { | |
| 597 | - | await page.goto(`${BASE}/my-repo/issues?status=completed`); | |
| 598 | - | const titles = await page.locator('.issue-title').allTextContents(); | |
| 599 | - | expect(titles.some(t => t.includes('To be completed'))).toBe(true); | |
| 600 | - | } finally { await page.close(); } | |
| 601 | - | }); | |
| 602 | - | ||
| 603 | - | test('non-admin cannot complete or close issue', async () => { | |
| 604 | - | const issueNum = issueUrl.split('/issues/')[1]; | |
| 605 | - | const ctx = await browser.newContext(); | |
| 606 | - | try { | |
| 607 | - | const completeResp = await ctx.request.post( | |
| 608 | - | `${BASE}/my-repo/issues/${issueNum}/complete`, | |
| 609 | - | { maxRedirects: 0 }, | |
| 610 | - | ); | |
| 611 | - | expect(completeResp.status()).toBe(302); | |
| 612 | - | expect(completeResp.headers()['location']).toContain('/login'); | |
| 613 | - | } finally { await ctx.close(); } | |
| 614 | - | }); | |
| 615 | - | ||
| 616 | - | test('reacting with same emoji toggles it off', async () => { | |
| 617 | - | const page = await adminCtx.newPage(); | |
| 618 | - | try { | |
| 619 | - | await page.goto(issueUrl); | |
| 620 | - | // Reaction was added by the earlier 'react to issue' test | |
| 621 | - | expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0); | |
| 622 | - | await page.locator('.reaction-btn').first().click(); | |
| 623 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 624 | - | expect(await page.locator('.reaction-btn').count()).toBe(0); | |
| 625 | - | } finally { await page.close(); } | |
| 626 | - | }); | |
| 627 | - | ||
| 628 | - | test('react to issue comment', async () => { | |
| 629 | - | const page = await adminCtx.newPage(); | |
| 630 | - | try { | |
| 631 | - | await page.goto(issueUrl); | |
| 632 | - | const commentItem = page.locator('.timeline-item:not(.timeline-item-new)') | |
| 633 | - | .filter({ hasText: 'A follow-up comment.' }); | |
| 634 | - | await commentItem.locator('.reaction-add-btn').click(); | |
| 635 | - | await commentItem.locator('.reaction-picker-btn').first().click(); | |
| 636 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 637 | - | expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0); | |
| 638 | - | } finally { await page.close(); } | |
| 639 | - | }); | |
| 640 | - | }); | |
| 641 | - | ||
| 642 | - | // ─── Patches ────────────────────────────────────────────────────────────────── | |
| 643 | - | ||
| 644 | - | describe('patches', () => { | |
| 645 | - | let adminCtx: BrowserContext; | |
| 646 | - | let cleanPatchUrl: string; | |
| 647 | - | let conflictPatchUrl: string; | |
| 648 | - | let closePatchUrl: string; | |
| 649 | - | ||
| 650 | - | // Adds a new file — applies cleanly to my-repo | |
| 651 | - | const CLEAN_PATCH = [ | |
| 652 | - | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 653 | - | 'From: Test User <test@example.com>', | |
| 654 | - | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 655 | - | 'Subject: [PATCH] Add patch-test.txt', | |
| 656 | - | '', | |
| 657 | - | '---', | |
| 658 | - | 'diff --git a/patch-test.txt b/patch-test.txt', | |
| 659 | - | 'new file mode 100644', | |
| 660 | - | 'index 0000000..9daeafb', | |
| 661 | - | '--- /dev/null', | |
| 662 | - | '+++ b/patch-test.txt', | |
| 663 | - | '@@ -0,0 +1 @@', | |
| 664 | - | '+patch test content', | |
| 665 | - | '', | |
| 666 | - | ].join('\n'); | |
| 667 | - | ||
| 668 | - | // References non-existent lines in README.md — always conflicts | |
| 669 | - | const CONFLICT_PATCH = [ | |
| 670 | - | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001', | |
| 671 | - | 'From: Test User <test@example.com>', | |
| 672 | - | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 673 | - | 'Subject: [PATCH] Modify README', | |
| 674 | - | '', | |
| 675 | - | '---', | |
| 676 | - | 'diff --git a/README.md b/README.md', | |
| 677 | - | 'index abc1234..def5678 100644', | |
| 678 | - | '--- a/README.md', | |
| 679 | - | '+++ b/README.md', | |
| 680 | - | '@@ -50,3 +50,3 @@', | |
| 681 | - | ' nonexistent context line', | |
| 682 | - | '-nonexistent old line', | |
| 683 | - | '+nonexistent new line', | |
| 684 | - | '', | |
| 685 | - | ].join('\n'); | |
| 686 | - | ||
| 687 | - | // Adds another new file — for testing close flow | |
| 688 | - | const CLOSE_PATCH = [ | |
| 689 | - | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001', | |
| 690 | - | 'From: Test User <test@example.com>', | |
| 691 | - | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 692 | - | 'Subject: [PATCH] Add patch-close.txt', | |
| 693 | - | '', | |
| 694 | - | '---', | |
| 695 | - | 'diff --git a/patch-close.txt b/patch-close.txt', | |
| 696 | - | 'new file mode 100644', | |
| 697 | - | 'index 0000000..9daeafb', | |
| 698 | - | '--- /dev/null', | |
| 699 | - | '+++ b/patch-close.txt', | |
| 700 | - | '@@ -0,0 +1 @@', | |
| 701 | - | '+close test', | |
| 702 | - | '', | |
| 703 | - | ].join('\n'); | |
| 704 | - | ||
| 705 | - | beforeAll(async () => { | |
| 706 | - | adminCtx = await loggedInContext(); | |
| 707 | - | }); | |
| 708 | - | ||
| 709 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 710 | - | ||
| 711 | - | test('reject file without patch markers', async () => { | |
| 712 | - | writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text'); | |
| 713 | - | const page = await adminCtx.newPage(); | |
| 714 | - | try { | |
| 715 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 716 | - | await page.fill('[name=title]', 'Bad patch'); | |
| 717 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt'); | |
| 718 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 719 | - | expect(await page.locator('.form-error').textContent()).toContain('valid patch'); | |
| 720 | - | } finally { await page.close(); } | |
| 721 | - | }); | |
| 722 | - | ||
| 723 | - | test('reject patch missing Subject header', async () => { | |
| 724 | - | writeTempFile('/tmp/no-subject.patch', [ | |
| 725 | - | 'From: Test User <test@example.com>', | |
| 726 | - | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 727 | - | '', | |
| 728 | - | '---', | |
| 729 | - | 'diff --git a/f.txt b/f.txt', | |
| 730 | - | 'new file mode 100644', | |
| 731 | - | '--- /dev/null', | |
| 732 | - | '+++ b/f.txt', | |
| 733 | - | '@@ -0,0 +1 @@', | |
| 734 | - | '+x', | |
| 735 | - | '', | |
| 736 | - | ].join('\n')); | |
| 737 | - | const page = await adminCtx.newPage(); | |
| 738 | - | try { | |
| 739 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 740 | - | await page.fill('[name=title]', 'No subject'); | |
| 741 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch'); | |
| 742 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 743 | - | expect(await page.locator('.form-error').textContent()).toContain('Subject'); | |
| 744 | - | } finally { await page.close(); } | |
| 745 | - | }); | |
| 746 | - | ||
| 747 | - | test('reject patch missing From header', async () => { | |
| 748 | - | writeTempFile('/tmp/no-from.patch', [ | |
| 749 | - | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 750 | - | 'Subject: [PATCH] Add f.txt', | |
| 751 | - | '', | |
| 752 | - | '---', | |
| 753 | - | 'diff --git a/f.txt b/f.txt', | |
| 754 | - | 'new file mode 100644', | |
| 755 | - | '--- /dev/null', | |
| 756 | - | '+++ b/f.txt', | |
| 757 | - | '@@ -0,0 +1 @@', | |
| 758 | - | '+x', | |
| 759 | - | '', | |
| 760 | - | ].join('\n')); | |
| 761 | - | const page = await adminCtx.newPage(); | |
| 762 | - | try { | |
| 763 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 764 | - | await page.fill('[name=title]', 'No from'); | |
| 765 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch'); | |
| 766 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 767 | - | expect(await page.locator('.form-error').textContent()).toContain('From'); | |
| 768 | - | } finally { await page.close(); } | |
| 769 | - | }); | |
| 770 | - | ||
| 771 | - | test('reject patch missing Date header', async () => { | |
| 772 | - | writeTempFile('/tmp/no-date.patch', [ | |
| 773 | - | 'From: Test User <test@example.com>', | |
| 774 | - | 'Subject: [PATCH] Add f.txt', | |
| 775 | - | '', | |
| 776 | - | '---', | |
| 777 | - | 'diff --git a/f.txt b/f.txt', | |
| 778 | - | 'new file mode 100644', | |
| 779 | - | '--- /dev/null', | |
| 780 | - | '+++ b/f.txt', | |
| 781 | - | '@@ -0,0 +1 @@', | |
| 782 | - | '+x', | |
| 783 | - | '', | |
| 784 | - | ].join('\n')); | |
| 785 | - | const page = await adminCtx.newPage(); | |
| 786 | - | try { | |
| 787 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 788 | - | await page.fill('[name=title]', 'No date'); | |
| 789 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch'); | |
| 790 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 791 | - | expect(await page.locator('.form-error').textContent()).toContain('Date'); | |
| 792 | - | } finally { await page.close(); } | |
| 793 | - | }); | |
| 794 | - | ||
| 795 | - | test('upload clean patch', async () => { | |
| 796 | - | writeTempFile('/tmp/clean.patch', CLEAN_PATCH); | |
| 797 | - | const page = await adminCtx.newPage(); | |
| 798 | - | try { | |
| 799 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 800 | - | await page.fill('[name=title]', 'Add patch-test.txt'); | |
| 801 | - | await page.fill('[name=description]', 'Adds a file with **markdown** desc.'); | |
| 802 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch'); | |
| 803 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 804 | - | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 805 | - | cleanPatchUrl = page.url(); | |
| 806 | - | expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt'); | |
| 807 | - | } finally { await page.close(); } | |
| 808 | - | }); | |
| 809 | - | ||
| 810 | - | test('changes tab shows commit metadata card', async () => { | |
| 811 | - | const page = await adminCtx.newPage(); | |
| 812 | - | try { | |
| 813 | - | await page.goto(cleanPatchUrl + '?tab=changes'); | |
| 814 | - | expect(await page.locator('.commit-card').isVisible()).toBe(true); | |
| 815 | - | expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt'); | |
| 816 | - | expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User'); | |
| 817 | - | expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com'); | |
| 818 | - | expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true); | |
| 819 | - | } finally { await page.close(); } | |
| 820 | - | }); | |
| 821 | - | ||
| 822 | - | test('patch description renders markdown', async () => { | |
| 823 | - | const page = await adminCtx.newPage(); | |
| 824 | - | try { | |
| 825 | - | await page.goto(cleanPatchUrl); | |
| 826 | - | expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>'); | |
| 827 | - | } finally { await page.close(); } | |
| 828 | - | }); | |
| 829 | - | ||
| 830 | - | test('clean patch shows apply-clean status immediately', async () => { | |
| 831 | - | const page = await adminCtx.newPage(); | |
| 832 | - | try { | |
| 833 | - | await page.goto(cleanPatchUrl); | |
| 834 | - | expect(await page.locator('.apply-result').isVisible()).toBe(true); | |
| 835 | - | expect(await page.locator('.apply-clean').isVisible()).toBe(true); | |
| 836 | - | } finally { await page.close(); } | |
| 837 | - | }); | |
| 838 | - | ||
| 839 | - | test('merge button appears for clean patch', async () => { | |
| 840 | - | const page = await adminCtx.newPage(); | |
| 841 | - | try { | |
| 842 | - | await page.goto(cleanPatchUrl); | |
| 843 | - | expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true); | |
| 844 | - | } finally { await page.close(); } | |
| 845 | - | }); | |
| 846 | - | ||
| 847 | - | test('patch diff is displayed with highlighted table', async () => { | |
| 848 | - | const page = await adminCtx.newPage(); | |
| 849 | - | try { | |
| 850 | - | await page.goto(cleanPatchUrl + '?tab=changes'); | |
| 851 | - | expect(await page.locator('.diff-table').first().isVisible()).toBe(true); | |
| 852 | - | expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0); | |
| 853 | - | } finally { await page.close(); } | |
| 854 | - | }); | |
| 855 | - | ||
| 856 | - | test('patch appears in open list', async () => { | |
| 857 | - | const page = await adminCtx.newPage(); | |
| 858 | - | try { | |
| 859 | - | await page.goto(`${BASE}/my-repo/patches`); | |
| 860 | - | const titles = await page.locator('.issue-title').allTextContents(); | |
| 861 | - | expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true); | |
| 862 | - | } finally { await page.close(); } | |
| 863 | - | }); | |
| 864 | - | ||
| 865 | - | test('unauthenticated user is redirected to login from patch upload', async () => { | |
| 866 | - | const ctx = await browser.newContext(); | |
| 867 | - | const page = await ctx.newPage(); | |
| 868 | - | try { | |
| 869 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 870 | - | expect(page.url()).toContain('/login'); | |
| 871 | - | } finally { await ctx.close(); } | |
| 872 | - | }); | |
| 873 | - | ||
| 874 | - | test('upload conflict patch', async () => { | |
| 875 | - | writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH); | |
| 876 | - | const page = await adminCtx.newPage(); | |
| 877 | - | try { | |
| 878 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 879 | - | await page.fill('[name=title]', 'Conflict patch'); | |
| 880 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch'); | |
| 881 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 882 | - | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 883 | - | conflictPatchUrl = page.url(); | |
| 884 | - | } finally { await page.close(); } | |
| 885 | - | }); | |
| 886 | - | ||
| 887 | - | test('conflict patch shows apply-conflict status', async () => { | |
| 888 | - | const page = await adminCtx.newPage(); | |
| 889 | - | try { | |
| 890 | - | await page.goto(conflictPatchUrl); | |
| 891 | - | expect(await page.locator('.apply-conflict').isVisible()).toBe(true); | |
| 892 | - | } finally { await page.close(); } | |
| 893 | - | }); | |
| 894 | - | ||
| 895 | - | test('merge button absent for conflict patch', async () => { | |
| 896 | - | const page = await adminCtx.newPage(); | |
| 897 | - | try { | |
| 898 | - | await page.goto(conflictPatchUrl); | |
| 899 | - | expect(await page.locator('form[action*="/merge"] button').count()).toBe(0); | |
| 900 | - | } finally { await page.close(); } | |
| 901 | - | }); | |
| 902 | - | ||
| 903 | - | test('merge clean patch changes status to merged', async () => { | |
| 904 | - | const page = await adminCtx.newPage(); | |
| 905 | - | try { | |
| 906 | - | await page.goto(cleanPatchUrl); | |
| 907 | - | await page.click('form[action*="/merge"] button'); | |
| 908 | - | await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, ''))); | |
| 909 | - | expect(await page.locator('.patch-badge').textContent()).toBe('merged'); | |
| 910 | - | } finally { await page.close(); } | |
| 911 | - | }); | |
| 912 | - | ||
| 913 | - | test('merge uses patch From header as git author', async () => { | |
| 914 | - | const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`; | |
| 915 | - | const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim(); | |
| 916 | - | const authorEmail = (await $`git -C ${repoPath} log -1 --format=%aE`.quiet()).text().trim(); | |
| 917 | - | const subject = (await $`git -C ${repoPath} log -1 --format=%s`.quiet()).text().trim(); | |
| 918 | - | expect(authorName).toBe('Test User'); | |
| 919 | - | expect(authorEmail).toBe('test@example.com'); | |
| 920 | - | expect(subject).toBe('Add patch-test.txt'); | |
| 921 | - | }); | |
| 922 | - | ||
| 923 | - | test('merged patch appears in merged list', async () => { | |
| 924 | - | const page = await adminCtx.newPage(); | |
| 925 | - | try { | |
| 926 | - | await page.goto(`${BASE}/my-repo/patches?status=merged`); | |
| 927 | - | const titles = await page.locator('.issue-title').allTextContents(); | |
| 928 | - | expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true); | |
| 929 | - | } finally { await page.close(); } | |
| 930 | - | }); | |
| 931 | - | ||
| 932 | - | test('upload and close a patch', async () => { | |
| 933 | - | writeTempFile('/tmp/close.patch', CLOSE_PATCH); | |
| 934 | - | const page = await adminCtx.newPage(); | |
| 935 | - | try { | |
| 936 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 937 | - | await page.fill('[name=title]', 'Close me'); | |
| 938 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch'); | |
| 939 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 940 | - | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 941 | - | closePatchUrl = page.url(); | |
| 942 | - | await page.click('form[action*="/close"] button'); | |
| 943 | - | await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, ''))); | |
| 944 | - | expect(await page.locator('.patch-badge').textContent()).toBe('closed'); | |
| 945 | - | } finally { await page.close(); } | |
| 946 | - | }); | |
| 947 | - | ||
| 948 | - | test('closed patch appears in closed list', async () => { | |
| 949 | - | const page = await adminCtx.newPage(); | |
| 950 | - | try { | |
| 951 | - | await page.goto(`${BASE}/my-repo/patches?status=closed`); | |
| 952 | - | const titles = await page.locator('.issue-title').allTextContents(); | |
| 953 | - | expect(titles.some(t => t.includes('Close me'))).toBe(true); | |
| 954 | - | } finally { await page.close(); } | |
| 955 | - | }); | |
| 956 | - | ||
| 957 | - | test('closed patch can be reopened', async () => { | |
| 958 | - | const page = await adminCtx.newPage(); | |
| 959 | - | try { | |
| 960 | - | await page.goto(closePatchUrl); | |
| 961 | - | expect(await page.locator('.patch-badge').textContent()).toBe('closed'); | |
| 962 | - | await page.click('form[action*="/close"] button'); | |
| 963 | - | await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, ''))); | |
| 964 | - | expect(await page.locator('.patch-badge').textContent()).toBe('open'); | |
| 965 | - | } finally { await page.close(); } | |
| 966 | - | }); | |
| 967 | - | ||
| 968 | - | test('patch title and description can be edited', async () => { | |
| 969 | - | const page = await adminCtx.newPage(); | |
| 970 | - | try { | |
| 971 | - | await page.goto(conflictPatchUrl); | |
| 972 | - | // Edit title via title form | |
| 973 | - | await page.click('details.title-edit-details summary'); | |
| 974 | - | await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch'); | |
| 975 | - | await page.click('.title-edit-form-area [type=submit]'); | |
| 976 | - | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 977 | - | expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch'); | |
| 978 | - | // Edit description via inline form | |
| 979 | - | await page.click('.timeline-author .inline-edit-details summary'); | |
| 980 | - | await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc'); | |
| 981 | - | await page.click('.inline-edit-form-area [type=submit]'); | |
| 982 | - | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 983 | - | } finally { await page.close(); } | |
| 984 | - | }); | |
| 985 | - | ||
| 986 | - | test('patch comment: add and edit', async () => { | |
| 987 | - | const page = await adminCtx.newPage(); | |
| 988 | - | try { | |
| 989 | - | await page.goto(conflictPatchUrl); | |
| 990 | - | await page.fill('[name=body]', 'My patch comment'); | |
| 991 | - | await page.click('form[action$="/comments"] button[type=submit]'); | |
| 992 | - | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 993 | - | expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment'); | |
| 994 | - | ||
| 995 | - | // Edit the comment — scope to the timeline-item containing the comment text | |
| 996 | - | const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' }); | |
| 997 | - | await commentItem.locator('.inline-edit-details summary').click(); | |
| 998 | - | await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment'); | |
| 999 | - | await commentItem.locator('.inline-edit-form-area [type=submit]').click(); | |
| 1000 | - | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 1001 | - | expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment'); | |
| 1002 | - | } finally { await page.close(); } | |
| 1003 | - | }); | |
| 1004 | - | ||
| 1005 | - | test('patch reaction on description', async () => { | |
| 1006 | - | const page = await adminCtx.newPage(); | |
| 1007 | - | try { | |
| 1008 | - | await page.goto(conflictPatchUrl); | |
| 1009 | - | // Open reaction picker on the first timeline-item (description) | |
| 1010 | - | await page.locator('.timeline-item').first().locator('.reaction-add-btn').click(); | |
| 1011 | - | await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click(); | |
| 1012 | - | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 1013 | - | expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/); | |
| 1014 | - | } finally { await page.close(); } | |
| 1015 | - | }); | |
| 1016 | - | ||
| 1017 | - | test('admin can delete a patch', async () => { | |
| 1018 | - | const page = await adminCtx.newPage(); | |
| 1019 | - | try { | |
| 1020 | - | const patchNum = conflictPatchUrl.split('/patches/')[1]; | |
| 1021 | - | const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, { | |
| 1022 | - | maxRedirects: 0, | |
| 1023 | - | }); | |
| 1024 | - | expect(resp.status()).toBe(302); | |
| 1025 | - | expect(resp.headers()['location']).toContain('/patches'); | |
| 1026 | - | // Patch should no longer be accessible | |
| 1027 | - | const checkResp = await page.request.get(conflictPatchUrl); | |
| 1028 | - | expect(checkResp.status()).toBe(404); | |
| 1029 | - | } finally { await page.close(); } | |
| 1030 | - | }); | |
| 1031 | - | ||
| 1032 | - | // ── Patch file re-upload & version protection ────────────────────────────── | |
| 1033 | - | ||
| 1034 | - | let uploadTestPatchUrl: string; | |
| 1035 | - | ||
| 1036 | - | // Adds upload-test.txt — applies cleanly to my-repo | |
| 1037 | - | const UPLOAD_TEST_PATCH = [ | |
| 1038 | - | 'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001', | |
| 1039 | - | 'From: Original Author <original@example.com>', | |
| 1040 | - | 'Date: Wed, 03 Jan 2024 10:00:00 +0000', | |
| 1041 | - | 'Subject: [PATCH] Add upload-test.txt', | |
| 1042 | - | '', | |
| 1043 | - | '---', | |
| 1044 | - | 'diff --git a/upload-test.txt b/upload-test.txt', | |
| 1045 | - | 'new file mode 100644', | |
| 1046 | - | 'index 0000000..9daeafb', | |
| 1047 | - | '--- /dev/null', | |
| 1048 | - | '+++ b/upload-test.txt', | |
| 1049 | - | '@@ -0,0 +1 @@', | |
| 1050 | - | '+upload test', | |
| 1051 | - | '', | |
| 1052 | - | ].join('\n'); | |
| 1053 | - | ||
| 1054 | - | // Replacement: different author, same diff target | |
| 1055 | - | const REPLACEMENT_PATCH = [ | |
| 1056 | - | 'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001', | |
| 1057 | - | 'From: Replaced Author <replaced@example.com>', | |
| 1058 | - | 'Date: Thu, 04 Jan 2024 10:00:00 +0000', | |
| 1059 | - | 'Subject: [PATCH] Add upload-test.txt (v2)', | |
| 1060 | - | '', | |
| 1061 | - | '---', | |
| 1062 | - | 'diff --git a/upload-test.txt b/upload-test.txt', | |
| 1063 | - | 'new file mode 100644', | |
| 1064 | - | 'index 0000000..9daeafb', | |
| 1065 | - | '--- /dev/null', | |
| 1066 | - | '+++ b/upload-test.txt', | |
| 1067 | - | '@@ -0,0 +1 @@', | |
| 1068 | - | '+upload test v2', | |
| 1069 | - | '', | |
| 1070 | - | ].join('\n'); | |
| 1071 | - | ||
| 1072 | - | test('create patch for re-upload tests', async () => { | |
| 1073 | - | writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH); | |
| 1074 | - | const page = await adminCtx.newPage(); | |
| 1075 | - | try { | |
| 1076 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 1077 | - | await page.fill('[name=title]', 'Upload test patch'); | |
| 1078 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch'); | |
| 1079 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 1080 | - | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 1081 | - | uploadTestPatchUrl = page.url(); | |
| 1082 | - | } finally { await page.close(); } | |
| 1083 | - | }); | |
| 1084 | - | ||
| 1085 | - | test('upload patch file button is visible for admin on open patch', async () => { | |
| 1086 | - | const page = await adminCtx.newPage(); | |
| 1087 | - | try { | |
| 1088 | - | await page.goto(uploadTestPatchUrl); | |
| 1089 | - | expect(await page.locator('details:has([name=patch_file])').count()).toBe(1); | |
| 1090 | - | } finally { await page.close(); } | |
| 1091 | - | }); | |
| 1092 | - | ||
| 1093 | - | test('non-author non-admin cannot upload patch file', async () => { | |
| 1094 | - | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1095 | - | const page = await aliceCtx.newPage(); | |
| 1096 | - | try { | |
| 1097 | - | const patchNum = uploadTestPatchUrl.split('/patches/')[1]; | |
| 1098 | - | const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, { | |
| 1099 | - | multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } }, | |
| 1100 | - | maxRedirects: 0, | |
| 1101 | - | }); | |
| 1102 | - | expect(resp.status()).toBe(403); | |
| 1103 | - | } finally { await aliceCtx.close(); } | |
| 1104 | - | }); | |
| 1105 | - | ||
| 1106 | - | test('upload button hidden for non-author non-admin', async () => { | |
| 1107 | - | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1108 | - | const page = await aliceCtx.newPage(); | |
| 1109 | - | try { | |
| 1110 | - | await page.goto(uploadTestPatchUrl); | |
| 1111 | - | expect(await page.locator('details:has([name=patch_file])').count()).toBe(0); | |
| 1112 | - | } finally { await aliceCtx.close(); } | |
| 1113 | - | }); | |
| 1114 | - | ||
| 1115 | - | test('admin can upload replacement patch file', async () => { | |
| 1116 | - | writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH); | |
| 1117 | - | const page = await adminCtx.newPage(); | |
| 1118 | - | try { | |
| 1119 | - | await page.goto(uploadTestPatchUrl); | |
| 1120 | - | await page.locator('details:has([name=patch_file]) summary').click(); | |
| 1121 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch'); | |
| 1122 | - | await page.locator('details:has([name=patch_file]) button[type=submit]').click(); | |
| 1123 | - | await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, ''))); | |
| 1124 | - | } finally { await page.close(); } | |
| 1125 | - | }); | |
| 1126 | - | ||
| 1127 | - | test('merge fails when version token is stale', async () => { | |
| 1128 | - | // Patch that adds stale-version.txt — applies cleanly | |
| 1129 | - | const STALE_PATCH = [ | |
| 1130 | - | 'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001', | |
| 1131 | - | 'From: Test User <test@example.com>', | |
| 1132 | - | 'Date: Fri, 05 Jan 2024 10:00:00 +0000', | |
| 1133 | - | 'Subject: [PATCH] Add stale-version.txt', | |
| 1134 | - | '', | |
| 1135 | - | '---', | |
| 1136 | - | 'diff --git a/stale-version.txt b/stale-version.txt', | |
| 1137 | - | 'new file mode 100644', | |
| 1138 | - | 'index 0000000..9daeafb', | |
| 1139 | - | '--- /dev/null', | |
| 1140 | - | '+++ b/stale-version.txt', | |
| 1141 | - | '@@ -0,0 +1 @@', | |
| 1142 | - | '+stale', | |
| 1143 | - | '', | |
| 1144 | - | ].join('\n'); | |
| 1145 | - | const STALE_PATCH_V2 = STALE_PATCH | |
| 1146 | - | .replace('Add stale-version.txt', 'Add stale-version.txt (v2)') | |
| 1147 | - | .replace('+stale', '+stale v2'); | |
| 1148 | - | ||
| 1149 | - | writeTempFile('/tmp/stale.patch', STALE_PATCH); | |
| 1150 | - | const page = await adminCtx.newPage(); | |
| 1151 | - | try { | |
| 1152 | - | // Create the patch | |
| 1153 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 1154 | - | await page.fill('[name=title]', 'Stale version test'); | |
| 1155 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch'); | |
| 1156 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 1157 | - | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 1158 | - | const stalePatchUrl = page.url(); | |
| 1159 | - | const patchNum = stalePatchUrl.split('/patches/')[1]; | |
| 1160 | - | ||
| 1161 | - | // Capture the version the admin sees on the page | |
| 1162 | - | const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue(); | |
| 1163 | - | ||
| 1164 | - | // Author uploads a new patch file (simulated by admin here), bumping the version | |
| 1165 | - | writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2); | |
| 1166 | - | await page.locator('details:has([name=patch_file]) summary').click(); | |
| 1167 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch'); | |
| 1168 | - | await page.locator('details:has([name=patch_file]) button[type=submit]').click(); | |
| 1169 | - | await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, ''))); | |
| 1170 | - | ||
| 1171 | - | // Admin tries to merge with the stale version — should be rejected | |
| 1172 | - | const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, { | |
| 1173 | - | form: { version: staleVersion }, | |
| 1174 | - | maxRedirects: 0, | |
| 1175 | - | }); | |
| 1176 | - | expect(resp.status()).toBe(409); | |
| 1177 | - | expect(await resp.text()).toContain('updated'); | |
| 1178 | - | ||
| 1179 | - | // Patch status must still be open | |
| 1180 | - | const checkResp = await page.request.get(stalePatchUrl); | |
| 1181 | - | expect(checkResp.status()).toBe(200); | |
| 1182 | - | expect(await checkResp.text()).toContain('open'); | |
| 1183 | - | } finally { await page.close(); } | |
| 1184 | - | }); | |
| 1185 | - | ||
| 1186 | - | test('merge succeeds with current version token after replacement upload', async () => { | |
| 1187 | - | const page = await adminCtx.newPage(); | |
| 1188 | - | try { | |
| 1189 | - | await page.goto(uploadTestPatchUrl); | |
| 1190 | - | await page.click('form[action*="/merge"] button'); | |
| 1191 | - | await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, ''))); | |
| 1192 | - | expect(await page.locator('.patch-badge').textContent()).toBe('merged'); | |
| 1193 | - | // Merged commit should carry the replacement patch's author | |
| 1194 | - | const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`; | |
| 1195 | - | const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim(); | |
| 1196 | - | expect(authorName).toBe('Replaced Author'); | |
| 1197 | - | } finally { await page.close(); } | |
| 1198 | - | }); | |
| 1199 | - | ||
| 1200 | - | test('upload patch file button hidden on merged patch', async () => { | |
| 1201 | - | const page = await adminCtx.newPage(); | |
| 1202 | - | try { | |
| 1203 | - | await page.goto(uploadTestPatchUrl); | |
| 1204 | - | expect(await page.locator('details:has([name=patch_file])').count()).toBe(0); | |
| 1205 | - | } finally { await page.close(); } | |
| 1206 | - | }); | |
| 1207 | - | ||
| 1208 | - | test('POST to upload on merged patch returns 400', async () => { | |
| 1209 | - | const patchNum = uploadTestPatchUrl.split('/patches/')[1]; | |
| 1210 | - | const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, { | |
| 1211 | - | multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } }, | |
| 1212 | - | maxRedirects: 0, | |
| 1213 | - | }); | |
| 1214 | - | expect(resp.status()).toBe(400); | |
| 1215 | - | }); | |
| 1216 | - | }); | |
| 1217 | - | ||
| 1218 | - | // ─── Pagination ─────────────────────────────────────────────────────────────── | |
| 1219 | - | ||
| 1220 | - | describe('pagination', () => { | |
| 1221 | - | let adminCtx: BrowserContext; | |
| 1222 | - | ||
| 1223 | - | beforeAll(async () => { | |
| 1224 | - | adminCtx = await loggedInContext(); | |
| 1225 | - | ||
| 1226 | - | // Create a repo dedicated to pagination testing | |
| 1227 | - | const page = await adminCtx.newPage(); | |
| 1228 | - | try { | |
| 1229 | - | await page.goto(`${BASE}/new`); | |
| 1230 | - | await page.fill('[name=name]', 'paged-repo'); | |
| 1231 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 1232 | - | await page.waitForURL(`${BASE}/paged-repo`); | |
| 1233 | - | } finally { await page.close(); } | |
| 1234 | - | ||
| 1235 | - | // Create 21 issues via the API (triggers page 2 at 20 per page) | |
| 1236 | - | await bulkCreateIssues(adminCtx, 'paged-repo', 21); | |
| 1237 | - | ||
| 1238 | - | // Create 21 patches via browser (patch upload requires multipart) | |
| 1239 | - | await bulkCreatePatches(adminCtx, 'paged-repo', 21); | |
| 1240 | - | }); | |
| 1241 | - | ||
| 1242 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 1243 | - | ||
| 1244 | - | // ── Repo list pagination ────────────────────────────────────────────────── | |
| 1245 | - | ||
| 1246 | - | test('repo list page 1 shows repos and no pagination when few repos', async () => { | |
| 1247 | - | // With only a handful of test repos (< 20), there should be no pagination nav | |
| 1248 | - | const page = await adminCtx.newPage(); | |
| 1249 | - | try { | |
| 1250 | - | await page.goto(BASE); | |
| 1251 | - | // Repos are shown | |
| 1252 | - | expect(await page.locator('.repo-name').count()).toBeGreaterThan(0); | |
| 1253 | - | } finally { await page.close(); } | |
| 1254 | - | }); | |
| 1255 | - | ||
| 1256 | - | // ── Issue pagination ────────────────────────────────────────────────────── | |
| 1257 | - | ||
| 1258 | - | test('issue list page 1 shows at most 20 items', async () => { | |
| 1259 | - | const page = await adminCtx.newPage(); | |
| 1260 | - | try { | |
| 1261 | - | await page.goto(`${BASE}/paged-repo/issues`); | |
| 1262 | - | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 1263 | - | } finally { await page.close(); } | |
| 1264 | - | }); | |
| 1265 | - | ||
| 1266 | - | test('issue list pagination nav appears when more than 20 issues', async () => { | |
| 1267 | - | const page = await adminCtx.newPage(); | |
| 1268 | - | try { | |
| 1269 | - | await page.goto(`${BASE}/paged-repo/issues`); | |
| 1270 | - | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 1271 | - | } finally { await page.close(); } | |
| 1272 | - | }); | |
| 1273 | - | ||
| 1274 | - | test('issue list page 2 shows remaining issues', async () => { | |
| 1275 | - | const page = await adminCtx.newPage(); | |
| 1276 | - | try { | |
| 1277 | - | await page.goto(`${BASE}/paged-repo/issues?page=2`); | |
| 1278 | - | const count = await page.locator('.issue-item').count(); | |
| 1279 | - | expect(count).toBeGreaterThan(0); | |
| 1280 | - | expect(count).toBeLessThanOrEqual(20); | |
| 1281 | - | } finally { await page.close(); } | |
| 1282 | - | }); | |
| 1283 | - | ||
| 1284 | - | test('issue list page 2 prev link goes to page 1', async () => { | |
| 1285 | - | const page = await adminCtx.newPage(); | |
| 1286 | - | try { | |
| 1287 | - | await page.goto(`${BASE}/paged-repo/issues?page=2`); | |
| 1288 | - | const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href'); | |
| 1289 | - | expect(prevHref).toContain('page=1'); | |
| 1290 | - | } finally { await page.close(); } | |
| 1291 | - | }); | |
| 1292 | - | ||
| 1293 | - | test('issue list page 1 next link goes to page 2', async () => { | |
| 1294 | - | const page = await adminCtx.newPage(); | |
| 1295 | - | try { | |
| 1296 | - | await page.goto(`${BASE}/paged-repo/issues`); | |
| 1297 | - | const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href'); | |
| 1298 | - | expect(nextHref).toContain('page=2'); | |
| 1299 | - | } finally { await page.close(); } | |
| 1300 | - | }); | |
| 1301 | - | ||
| 1302 | - | // ── Patch pagination ────────────────────────────────────────────────────── | |
| 1303 | - | ||
| 1304 | - | test('patch list page 1 shows at most 20 items', async () => { | |
| 1305 | - | const page = await adminCtx.newPage(); | |
| 1306 | - | try { | |
| 1307 | - | await page.goto(`${BASE}/paged-repo/patches`); | |
| 1308 | - | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 1309 | - | } finally { await page.close(); } | |
| 1310 | - | }); | |
| 1311 | - | ||
| 1312 | - | test('patch list pagination nav appears when more than 20 patches', async () => { | |
| 1313 | - | const page = await adminCtx.newPage(); | |
| 1314 | - | try { | |
| 1315 | - | await page.goto(`${BASE}/paged-repo/patches`); | |
| 1316 | - | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 1317 | - | } finally { await page.close(); } | |
| 1318 | - | }); | |
| 1319 | - | ||
| 1320 | - | test('patch list page 2 shows remaining patches', async () => { | |
| 1321 | - | const page = await adminCtx.newPage(); | |
| 1322 | - | try { | |
| 1323 | - | await page.goto(`${BASE}/paged-repo/patches?page=2`); | |
| 1324 | - | const count = await page.locator('.issue-item').count(); | |
| 1325 | - | expect(count).toBeGreaterThan(0); | |
| 1326 | - | } finally { await page.close(); } | |
| 1327 | - | }); | |
| 1328 | - | ||
| 1329 | - | // ── Commit log pagination ───────────────────────────────────────────────── | |
| 1330 | - | ||
| 1331 | - | test('commit log with few commits shows no cursor nav', async () => { | |
| 1332 | - | // my-repo has 1 commit — both newer and older links should be absent | |
| 1333 | - | const page = await adminCtx.newPage(); | |
| 1334 | - | try { | |
| 1335 | - | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 1336 | - | expect(await page.locator('.commit-cursor-nav').count()).toBe(0); | |
| 1337 | - | } finally { await page.close(); } | |
| 1338 | - | }); | |
| 1339 | - | }); | |
| 1340 | - | ||
| 1341 | - | // ─── Branch selector ────────────────────────────────────────────────────────── | |
| 1342 | - | ||
| 1343 | - | describe('branch selector', () => { | |
| 1344 | - | let adminCtx: BrowserContext; | |
| 1345 | - | ||
| 1346 | - | beforeAll(async () => { | |
| 1347 | - | adminCtx = await loggedInContext(); | |
| 1348 | - | // Add a second branch so the selector is meaningful | |
| 1349 | - | await seedBranch('my-repo', 'dev'); | |
| 1350 | - | }); | |
| 1351 | - | ||
| 1352 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 1353 | - | ||
| 1354 | - | test('branch selector appears on repo home', async () => { | |
| 1355 | - | const page = await adminCtx.newPage(); | |
| 1356 | - | try { | |
| 1357 | - | await page.goto(`${BASE}/my-repo`); | |
| 1358 | - | expect(await page.locator('.branch-selector').isVisible()).toBe(true); | |
| 1359 | - | expect(await page.locator('.branch-select').inputValue()).toBe('main'); | |
| 1360 | - | } finally { await page.close(); } | |
| 1361 | - | }); | |
| 1362 | - | ||
| 1363 | - | test('branch selector shows all branches on repo home', async () => { | |
| 1364 | - | const page = await adminCtx.newPage(); | |
| 1365 | - | try { | |
| 1366 | - | await page.goto(`${BASE}/my-repo`); | |
| 1367 | - | const options = await page.locator('.branch-select option').allTextContents(); | |
| 1368 | - | expect(options).toContain('main'); | |
| 1369 | - | expect(options).toContain('dev'); | |
| 1370 | - | } finally { await page.close(); } | |
| 1371 | - | }); | |
| 1372 | - | ||
| 1373 | - | test('branch selector appears on file tree with current ref selected', async () => { | |
| 1374 | - | const page = await adminCtx.newPage(); | |
| 1375 | - | try { | |
| 1376 | - | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 1377 | - | expect(await page.locator('.branch-selector').isVisible()).toBe(true); | |
| 1378 | - | expect(await page.locator('.branch-select').inputValue()).toBe('main'); | |
| 1379 | - | } finally { await page.close(); } | |
| 1380 | - | }); | |
| 1381 | - | ||
| 1382 | - | test('branch selector appears on commit log with current ref selected', async () => { | |
| 1383 | - | const page = await adminCtx.newPage(); | |
| 1384 | - | try { | |
| 1385 | - | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 1386 | - | expect(await page.locator('.branch-selector').isVisible()).toBe(true); | |
| 1387 | - | expect(await page.locator('.branch-select').inputValue()).toBe('main'); | |
| 1388 | - | } finally { await page.close(); } | |
| 1389 | - | }); | |
| 1390 | - | ||
| 1391 | - | test('branch selector appears on file blob', async () => { | |
| 1392 | - | const page = await adminCtx.newPage(); | |
| 1393 | - | try { | |
| 1394 | - | await page.goto(`${BASE}/my-repo/blob/main/README.md`); | |
| 1395 | - | expect(await page.locator('.branch-selector').isVisible()).toBe(true); | |
| 1396 | - | expect(await page.locator('.branch-select').inputValue()).toBe('main'); | |
| 1397 | - | } finally { await page.close(); } | |
| 1398 | - | }); | |
| 1399 | - | ||
| 1400 | - | test('switching branch on commit log navigates to the selected branch', async () => { | |
| 1401 | - | const page = await adminCtx.newPage(); | |
| 1402 | - | try { | |
| 1403 | - | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 1404 | - | await page.locator('.branch-select').selectOption('dev'); | |
| 1405 | - | await page.locator('form.branch-selector').evaluate((f: any) => f.submit()); | |
| 1406 | - | await page.waitForURL(`${BASE}/my-repo/commits/dev`); | |
| 1407 | - | expect(page.url()).toContain('/commits/dev'); | |
| 1408 | - | } finally { await page.close(); } | |
| 1409 | - | }); | |
| 1410 | - | ||
| 1411 | - | test('switching branch on file tree navigates to the selected branch', async () => { | |
| 1412 | - | const page = await adminCtx.newPage(); | |
| 1413 | - | try { | |
| 1414 | - | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 1415 | - | await page.locator('.branch-select').selectOption('dev'); | |
| 1416 | - | await page.locator('form.branch-selector').evaluate((f: any) => f.submit()); | |
| 1417 | - | await page.waitForURL(`${BASE}/my-repo/tree/dev`); | |
| 1418 | - | expect(page.url()).toContain('/tree/dev'); | |
| 1419 | - | } finally { await page.close(); } | |
| 1420 | - | }); | |
| 1421 | - | ||
| 1422 | - | test('branch-switch route preserves subpath when switching tree', async () => { | |
| 1423 | - | const page = await adminCtx.newPage(); | |
| 1424 | - | try { | |
| 1425 | - | const resp = await page.request.get( | |
| 1426 | - | `${BASE}/my-repo/branch-switch?view=tree&rev=dev&path=src/foo`, | |
| 1427 | - | { maxRedirects: 0 }, | |
| 1428 | - | ).catch(r => r); | |
| 1429 | - | // 302 redirect to /my-repo/tree/dev/src/foo | |
| 1430 | - | const loc = (resp as any).headers()?.['location'] ?? ''; | |
| 1431 | - | expect(loc).toContain('/tree/dev/src/foo'); | |
| 1432 | - | } finally { await page.close(); } | |
| 1433 | - | }); | |
| 1434 | - | ||
| 1435 | - | test('branch-switch route redirects commits view correctly', async () => { | |
| 1436 | - | const page = await adminCtx.newPage(); | |
| 1437 | - | try { | |
| 1438 | - | const resp = await page.request.get( | |
| 1439 | - | `${BASE}/my-repo/branch-switch?view=commits&rev=dev`, | |
| 1440 | - | { maxRedirects: 0 }, | |
| 1441 | - | ).catch(r => r); | |
| 1442 | - | const loc = (resp as any).headers()?.['location'] ?? ''; | |
| 1443 | - | expect(loc).toContain('/commits/dev'); | |
| 1444 | - | } finally { await page.close(); } | |
| 1445 | - | }); | |
| 1446 | - | ||
| 1447 | - | test('branch-switch route redirects blob view correctly', async () => { | |
| 1448 | - | const page = await adminCtx.newPage(); | |
| 1449 | - | try { | |
| 1450 | - | const resp = await page.request.get( | |
| 1451 | - | `${BASE}/my-repo/branch-switch?view=blob&rev=dev&path=README.md`, | |
| 1452 | - | { maxRedirects: 0 }, | |
| 1453 | - | ).catch(r => r); | |
| 1454 | - | const loc = (resp as any).headers()?.['location'] ?? ''; | |
| 1455 | - | expect(loc).toContain('/blob/dev/README.md'); | |
| 1456 | - | } finally { await page.close(); } | |
| 1457 | - | }); | |
| 1458 | - | }); | |
| 1459 | - | ||
| 1460 | - | // ─── Default branch settings ────────────────────────────────────────────────── | |
| 1461 | - | ||
| 1462 | - | describe('default branch settings', () => { | |
| 1463 | - | // Runs after 'branch selector', so my-repo already has both main and dev branches. | |
| 1464 | - | let adminCtx: BrowserContext; | |
| 1465 | - | ||
| 1466 | - | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 1467 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 1468 | - | ||
| 1469 | - | test('settings page shows default branch select', async () => { | |
| 1470 | - | const page = await adminCtx.newPage(); | |
| 1471 | - | try { | |
| 1472 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 1473 | - | expect(await page.locator('#default_branch').isVisible()).toBe(true); | |
| 1474 | - | const options = await page.locator('#default_branch option').allTextContents(); | |
| 1475 | - | expect(options).toContain('main'); | |
| 1476 | - | expect(options).toContain('dev'); | |
| 1477 | - | } finally { await page.close(); } | |
| 1478 | - | }); | |
| 1479 | - | ||
| 1480 | - | test('current default branch is pre-selected', async () => { | |
| 1481 | - | const page = await adminCtx.newPage(); | |
| 1482 | - | try { | |
| 1483 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 1484 | - | expect(await page.locator('#default_branch').inputValue()).toBe('main'); | |
| 1485 | - | } finally { await page.close(); } | |
| 1486 | - | }); | |
| 1487 | - | ||
| 1488 | - | test('changing default branch saves and is reflected in the repo home', async () => { | |
| 1489 | - | const page = await adminCtx.newPage(); | |
| 1490 | - | try { | |
| 1491 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 1492 | - | await page.locator('#default_branch').selectOption('dev'); | |
| 1493 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 1494 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 1495 | - | // The select now shows dev as current | |
| 1496 | - | expect(await page.locator('#default_branch').inputValue()).toBe('dev'); | |
| 1497 | - | ||
| 1498 | - | // Repo home branch selector should reflect the new default | |
| 1499 | - | await page.goto(`${BASE}/my-repo`); | |
| 1500 | - | expect(await page.locator('.branch-select').inputValue()).toBe('dev'); | |
| 1501 | - | } finally { await page.close(); } | |
| 1502 | - | }); | |
| 1503 | - | ||
| 1504 | - | test('commit log link in repo nav uses the new default branch', async () => { | |
| 1505 | - | const page = await adminCtx.newPage(); | |
| 1506 | - | try { | |
| 1507 | - | await page.goto(`${BASE}/my-repo`); | |
| 1508 | - | const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href'); | |
| 1509 | - | expect(commitsHref).toContain('/commits/dev'); | |
| 1510 | - | } finally { await page.close(); } | |
| 1511 | - | }); | |
| 1512 | - | ||
| 1513 | - | test('changing default branch back to main restores original state', async () => { | |
| 1514 | - | const page = await adminCtx.newPage(); | |
| 1515 | - | try { | |
| 1516 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 1517 | - | await page.locator('#default_branch').selectOption('main'); | |
| 1518 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 1519 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 1520 | - | expect(await page.locator('#default_branch').inputValue()).toBe('main'); | |
| 1521 | - | } finally { await page.close(); } | |
| 1522 | - | }); | |
| 1523 | - | ||
| 1524 | - | test('settings page shows hint instead of select when repo has no branches', async () => { | |
| 1525 | - | // Create an empty repo (no commits → no branches) | |
| 1526 | - | const page = await adminCtx.newPage(); | |
| 1527 | - | try { | |
| 1528 | - | await page.goto(`${BASE}/new`); | |
| 1529 | - | await page.fill('[name=name]', 'empty-for-branch-test'); | |
| 1530 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 1531 | - | await page.waitForURL(`${BASE}/empty-for-branch-test`); | |
| 1532 | - | ||
| 1533 | - | await page.goto(`${BASE}/empty-for-branch-test/settings`); | |
| 1534 | - | expect(await page.locator('#default_branch').count()).toBe(0); | |
| 1535 | - | expect(await page.locator('.form-hint').isVisible()).toBe(true); | |
| 1536 | - | } finally { await page.close(); } | |
| 1537 | - | }); | |
| 1538 | - | }); | |
| 1539 | - | ||
| 1540 | - | // ─── File browser ───────────────────────────────────────────────────────────── | |
| 1541 | - | ||
| 1542 | - | describe('file browser', () => { | |
| 1543 | - | // my-repo already has README.md + index.js from the 'repos' describe block. | |
| 1544 | - | // We add a subdirectory here so we can test directory navigation. | |
| 1545 | - | let adminCtx: BrowserContext; | |
| 1546 | - | ||
| 1547 | - | beforeAll(async () => { | |
| 1548 | - | adminCtx = await loggedInContext(); | |
| 1549 | - | await seedSubdir('my-repo', 'src', { | |
| 1550 | - | 'app.ts': 'export {};\n', | |
| 1551 | - | 'README.md': '# src readme\n', | |
| 1552 | - | }); | |
| 1553 | - | }); | |
| 1554 | - | ||
| 1555 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 1556 | - | ||
| 1557 | - | test('repo home shows file tree instead of recent commits', async () => { | |
| 1558 | - | const page = await adminCtx.newPage(); | |
| 1559 | - | try { | |
| 1560 | - | await page.goto(`${BASE}/my-repo`); | |
| 1561 | - | expect(await page.locator('.file-tree').isVisible()).toBe(true); | |
| 1562 | - | expect(await page.locator('.repo-commits-section').count()).toBe(0); | |
| 1563 | - | } finally { await page.close(); } | |
| 1564 | - | }); | |
| 1565 | - | ||
| 1566 | - | test('repo home file tree lists files and directories', async () => { | |
| 1567 | - | const page = await adminCtx.newPage(); | |
| 1568 | - | try { | |
| 1569 | - | await page.goto(`${BASE}/my-repo`); | |
| 1570 | - | const names = await page.locator('.file-name a').allTextContents(); | |
| 1571 | - | expect(names).toContain('README.md'); | |
| 1572 | - | expect(names).toContain('index.js'); | |
| 1573 | - | expect(names).toContain('src'); | |
| 1574 | - | } finally { await page.close(); } | |
| 1575 | - | }); | |
| 1576 | - | ||
| 1577 | - | test('directories appear before files in file tree', async () => { | |
| 1578 | - | const page = await adminCtx.newPage(); | |
| 1579 | - | try { | |
| 1580 | - | await page.goto(`${BASE}/my-repo`); | |
| 1581 | - | const names = await page.locator('.file-name a').allTextContents(); | |
| 1582 | - | const srcIdx = names.indexOf('src'); | |
| 1583 | - | const readmeIdx = names.indexOf('README.md'); | |
| 1584 | - | expect(srcIdx).toBeGreaterThanOrEqual(0); | |
| 1585 | - | expect(readmeIdx).toBeGreaterThanOrEqual(0); | |
| 1586 | - | expect(srcIdx).toBeLessThan(readmeIdx); | |
| 1587 | - | } finally { await page.close(); } | |
| 1588 | - | }); | |
| 1589 | - | ||
| 1590 | - | test('no ".." entry at repository root', async () => { | |
| 1591 | - | const page = await adminCtx.newPage(); | |
| 1592 | - | try { | |
| 1593 | - | await page.goto(`${BASE}/my-repo`); | |
| 1594 | - | const names = await page.locator('.file-name a').allTextContents(); | |
| 1595 | - | expect(names).not.toContain('..'); | |
| 1596 | - | } finally { await page.close(); } | |
| 1597 | - | }); | |
| 1598 | - | ||
| 1599 | - | test('clicking directory navigates into it', async () => { | |
| 1600 | - | const page = await adminCtx.newPage(); | |
| 1601 | - | try { | |
| 1602 | - | await page.goto(`${BASE}/my-repo`); | |
| 1603 | - | await page.locator('.file-name a', { hasText: 'src' }).click(); | |
| 1604 | - | await page.waitForURL(`${BASE}/my-repo/tree/main/src`); | |
| 1605 | - | expect(page.url()).toContain('/tree/main/src'); | |
| 1606 | - | } finally { await page.close(); } | |
| 1607 | - | }); | |
| 1608 | - | ||
| 1609 | - | test('".." entry appears in subdirectory', async () => { | |
| 1610 | - | const page = await adminCtx.newPage(); | |
| 1611 | - | try { | |
| 1612 | - | await page.goto(`${BASE}/my-repo/tree/main/src`); | |
| 1613 | - | const names = await page.locator('.file-name a').allTextContents(); | |
| 1614 | - | expect(names).toContain('..'); | |
| 1615 | - | } finally { await page.close(); } | |
| 1616 | - | }); | |
| 1617 | - | ||
| 1618 | - | test('".." at one level deep links to tree root', async () => { | |
| 1619 | - | const page = await adminCtx.newPage(); | |
| 1620 | - | try { | |
| 1621 | - | await page.goto(`${BASE}/my-repo/tree/main/src`); | |
| 1622 | - | const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href'); | |
| 1623 | - | expect(upHref).toBe('/my-repo/tree/main'); | |
| 1624 | - | } finally { await page.close(); } | |
| 1625 | - | }); | |
| 1626 | - | ||
| 1627 | - | test('files in subdirectory show plain names, not full paths', async () => { | |
| 1628 | - | const page = await adminCtx.newPage(); | |
| 1629 | - | try { | |
| 1630 | - | await page.goto(`${BASE}/my-repo/tree/main/src`); | |
| 1631 | - | const names = await page.locator('.file-name a').allTextContents(); | |
| 1632 | - | expect(names).toContain('app.ts'); | |
| 1633 | - | // Must NOT contain the full path with prefix | |
| 1634 | - | expect(names).not.toContain('src/app.ts'); | |
| 1635 | - | expect(names).not.toContain('src/README.md'); | |
| 1636 | - | } finally { await page.close(); } | |
| 1637 | - | }); | |
| 1638 | - | ||
| 1639 | - | test('readme is shown below file tree on repo home', async () => { | |
| 1640 | - | const page = await adminCtx.newPage(); | |
| 1641 | - | try { | |
| 1642 | - | await page.goto(`${BASE}/my-repo`); | |
| 1643 | - | const treeBox = await page.locator('.file-tree').boundingBox(); | |
| 1644 | - | const readmeBox = await page.locator('.readme-section').boundingBox(); | |
| 1645 | - | expect(treeBox).not.toBeNull(); | |
| 1646 | - | expect(readmeBox).not.toBeNull(); | |
| 1647 | - | expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1); | |
| 1648 | - | } finally { await page.close(); } | |
| 1649 | - | }); | |
| 1650 | - | ||
| 1651 | - | test('readme in subdirectory is shown when present', async () => { | |
| 1652 | - | const page = await adminCtx.newPage(); | |
| 1653 | - | try { | |
| 1654 | - | await page.goto(`${BASE}/my-repo/tree/main/src`); | |
| 1655 | - | expect(await page.locator('.readme-section').isVisible()).toBe(true); | |
| 1656 | - | expect(await page.locator('.readme-section .markdown-body').innerHTML()) | |
| 1657 | - | .toContain('src readme'); | |
| 1658 | - | } finally { await page.close(); } | |
| 1659 | - | }); | |
| 1660 | - | ||
| 1661 | - | test('file tree on /tree/:ref also shows readme', async () => { | |
| 1662 | - | const page = await adminCtx.newPage(); | |
| 1663 | - | try { | |
| 1664 | - | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 1665 | - | expect(await page.locator('.file-tree').isVisible()).toBe(true); | |
| 1666 | - | expect(await page.locator('.readme-section').isVisible()).toBe(true); | |
| 1667 | - | } finally { await page.close(); } | |
| 1668 | - | }); | |
| 1669 | - | }); | |
| 1670 | - | ||
| 1671 | - | // ─── Releases ───────────────────────────────────────────────────────────────── | |
| 1672 | - | ||
| 1673 | - | describe('releases', () => { | |
| 1674 | - | let adminCtx: BrowserContext; | |
| 1675 | - | let aliceCtx: BrowserContext; | |
| 1676 | - | let releaseUrl: string; | |
| 1677 | - | let srcReleaseUrl: string; | |
| 1678 | - | let releaseWithAssetsUrl: string; | |
| 1679 | - | ||
| 1680 | - | beforeAll(async () => { | |
| 1681 | - | adminCtx = await loggedInContext(); | |
| 1682 | - | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1683 | - | ||
| 1684 | - | // Create a dedicated repo with at least one commit | |
| 1685 | - | const page = await adminCtx.newPage(); | |
| 1686 | - | try { | |
| 1687 | - | await page.goto(`${BASE}/new`); | |
| 1688 | - | await page.fill('[name=name]', 'releases-repo'); | |
| 1689 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 1690 | - | await page.waitForURL(`${BASE}/releases-repo`); | |
| 1691 | - | } finally { await page.close(); } | |
| 1692 | - | ||
| 1693 | - | await seedRepo('releases-repo'); | |
| 1694 | - | }); | |
| 1695 | - | ||
| 1696 | - | afterAll(async () => { | |
| 1697 | - | await adminCtx.close(); | |
| 1698 | - | await aliceCtx.close(); | |
| 1699 | - | }); | |
| 1700 | - | ||
| 1701 | - | // ── Navigation ────────────────────────────────────────────────────────────── | |
| 1702 | - | ||
| 1703 | - | test('releases tab visible in repo nav', async () => { | |
| 1704 | - | const page = await adminCtx.newPage(); | |
| 1705 | - | try { | |
| 1706 | - | await page.goto(`${BASE}/releases-repo`); | |
| 1707 | - | expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true); | |
| 1708 | - | } finally { await page.close(); } | |
| 1709 | - | }); | |
| 1710 | - | ||
| 1711 | - | test('releases list shows empty state when no releases', async () => { | |
| 1712 | - | const page = await adminCtx.newPage(); | |
| 1713 | - | try { | |
| 1714 | - | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1715 | - | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 1716 | - | } finally { await page.close(); } | |
| 1717 | - | }); | |
| 1718 | - | ||
| 1719 | - | // ── Access control ────────────────────────────────────────────────────────── | |
| 1720 | - | ||
| 1721 | - | test('non-admin cannot access /releases/new', async () => { | |
| 1722 | - | const page = await aliceCtx.newPage(); | |
| 1723 | - | try { | |
| 1724 | - | const resp = await page.request.get(`${BASE}/releases-repo/releases/new`); | |
| 1725 | - | expect(resp.status()).toBe(403); | |
| 1726 | - | } finally { await page.close(); } | |
| 1727 | - | }); | |
| 1728 | - | ||
| 1729 | - | test('non-admin POST to /releases returns 403', async () => { | |
| 1730 | - | const page = await aliceCtx.newPage(); | |
| 1731 | - | try { | |
| 1732 | - | const resp = await page.request.post(`${BASE}/releases-repo/releases`, { | |
| 1733 | - | multipart: { name: 'Test', tag_name: 'v0.1.0' }, | |
| 1734 | - | maxRedirects: 0, | |
| 1735 | - | }); | |
| 1736 | - | expect(resp.status()).toBe(403); | |
| 1737 | - | } finally { await page.close(); } | |
| 1738 | - | }); | |
| 1739 | - | ||
| 1740 | - | test('unauthenticated user is redirected to login from /releases/new', async () => { | |
| 1741 | - | const ctx = await browser.newContext(); | |
| 1742 | - | const page = await ctx.newPage(); | |
| 1743 | - | try { | |
| 1744 | - | await page.goto(`${BASE}/releases-repo/releases/new`); | |
| 1745 | - | expect(page.url()).toContain('/login'); | |
| 1746 | - | } finally { await ctx.close(); } | |
| 1747 | - | }); | |
| 1748 | - | ||
| 1749 | - | // ── Validation ────────────────────────────────────────────────────────────── | |
| 1750 | - | ||
| 1751 | - | test('missing release title shows error', async () => { | |
| 1752 | - | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1753 | - | multipart: {}, | |
| 1754 | - | }); | |
| 1755 | - | expect(await resp.text()).toContain('Release title is required'); | |
| 1756 | - | }); | |
| 1757 | - | ||
| 1758 | - | test('create_tag checked but no tag name shows error', async () => { | |
| 1759 | - | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1760 | - | multipart: { name: 'Test', create_tag: 'on' }, | |
| 1761 | - | }); | |
| 1762 | - | expect(await resp.text()).toContain('Tag name is required'); | |
| 1763 | - | }); | |
| 1764 | - | ||
| 1765 | - | // ── Create ────────────────────────────────────────────────────────────────── | |
| 1766 | - | ||
| 1767 | - | test('create a basic release', async () => { | |
| 1768 | - | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1769 | - | multipart: { | |
| 1770 | - | create_tag: 'on', | |
| 1771 | - | tag_name: 'v1.0.0', | |
| 1772 | - | revision: 'main', | |
| 1773 | - | name: 'First release', | |
| 1774 | - | notes: 'Initial stable release.\n\n- Feature A\n- Feature B', | |
| 1775 | - | }, | |
| 1776 | - | maxRedirects: 0, | |
| 1777 | - | }); | |
| 1778 | - | expect(resp.status()).toBe(302); | |
| 1779 | - | const location = resp.headers()['location']!; | |
| 1780 | - | expect(location).toMatch(/\/releases-repo\/releases\/\d+/); | |
| 1781 | - | releaseUrl = `${BASE}${location}`; | |
| 1782 | - | }); | |
| 1783 | - | ||
| 1784 | - | test('duplicate tag name shows error', async () => { | |
| 1785 | - | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1786 | - | multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' }, | |
| 1787 | - | }); | |
| 1788 | - | expect(await resp.text()).toContain('already exists'); | |
| 1789 | - | }); | |
| 1790 | - | ||
| 1791 | - | // ── List ──────────────────────────────────────────────────────────────────── | |
| 1792 | - | ||
| 1793 | - | test('release appears in list with tag badge', async () => { | |
| 1794 | - | const page = await adminCtx.newPage(); | |
| 1795 | - | try { | |
| 1796 | - | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1797 | - | expect(await page.locator('.release-item-title').textContent()).toContain('First release'); | |
| 1798 | - | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); | |
| 1799 | - | } finally { await page.close(); } | |
| 1800 | - | }); | |
| 1801 | - | ||
| 1802 | - | test('release list shows tag badge', async () => { | |
| 1803 | - | const page = await adminCtx.newPage(); | |
| 1804 | - | try { | |
| 1805 | - | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1806 | - | expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0'); | |
| 1807 | - | } finally { await page.close(); } | |
| 1808 | - | }); | |
| 1809 | - | ||
| 1810 | - | test('new release button hidden for non-admin', async () => { | |
| 1811 | - | const page = await aliceCtx.newPage(); | |
| 1812 | - | try { | |
| 1813 | - | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1814 | - | expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0); | |
| 1815 | - | } finally { await page.close(); } | |
| 1816 | - | }); | |
| 1817 | - | ||
| 1818 | - | // ── Detail ────────────────────────────────────────────────────────────────── | |
| 1819 | - | ||
| 1820 | - | test('release detail shows title and tag badge', async () => { | |
| 1821 | - | const page = await adminCtx.newPage(); | |
| 1822 | - | try { | |
| 1823 | - | await page.goto(releaseUrl); | |
| 1824 | - | expect(await page.locator('h2.page-title').textContent()).toBe('First release'); | |
| 1825 | - | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); | |
| 1826 | - | } finally { await page.close(); } | |
| 1827 | - | }); | |
| 1828 | - | ||
| 1829 | - | test('release notes rendered in detail view', async () => { | |
| 1830 | - | const page = await adminCtx.newPage(); | |
| 1831 | - | try { | |
| 1832 | - | await page.goto(releaseUrl); | |
| 1833 | - | expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release'); | |
| 1834 | - | } finally { await page.close(); } | |
| 1835 | - | }); | |
| 1836 | - | ||
| 1837 | - | // ── Source archives ───────────────────────────────────────────────────────── | |
| 1838 | - | ||
| 1839 | - | test('create release with source code archives', async () => { | |
| 1840 | - | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1841 | - | multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' }, | |
| 1842 | - | maxRedirects: 0, | |
| 1843 | - | }); | |
| 1844 | - | expect(resp.status()).toBe(302); | |
| 1845 | - | const location = resp.headers()['location']!; | |
| 1846 | - | srcReleaseUrl = `${BASE}${location}`; | |
| 1847 | - | }); | |
| 1848 | - | ||
| 1849 | - | test('zip and tar.gz archives appear in downloads', async () => { | |
| 1850 | - | const page = await adminCtx.newPage(); | |
| 1851 | - | try { | |
| 1852 | - | await page.goto(srcReleaseUrl); | |
| 1853 | - | const assetNames = await page.locator('.asset-name').allTextContents(); | |
| 1854 | - | expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true); | |
| 1855 | - | expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true); | |
| 1856 | - | } finally { await page.close(); } | |
| 1857 | - | }); | |
| 1858 | - | ||
| 1859 | - | test('source archive download responds with 200', async () => { | |
| 1860 | - | const page = await adminCtx.newPage(); | |
| 1861 | - | try { | |
| 1862 | - | await page.goto(srcReleaseUrl); | |
| 1863 | - | const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href'); | |
| 1864 | - | const resp = await page.request.get(`${BASE}${zipLink}`); | |
| 1865 | - | expect(resp.status()).toBe(200); | |
| 1866 | - | } finally { await page.close(); } | |
| 1867 | - | }); | |
| 1868 | - | ||
| 1869 | - | // ── File upload ───────────────────────────────────────────────────────────── | |
| 1870 | - | ||
| 1871 | - | test('create release with attached file', async () => { | |
| 1872 | - | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1873 | - | multipart: { | |
| 1874 | - | name: 'Asset release', | |
| 1875 | - | create_tag: 'on', | |
| 1876 | - | tag_name: 'v1.2.0', | |
| 1877 | - | revision: 'main', | |
| 1878 | - | files: { | |
| 1879 | - | name: 'release-asset.txt', | |
| 1880 | - | mimeType: 'text/plain', | |
| 1881 | - | buffer: Buffer.from('binary-like content for testing\n'), | |
| 1882 | - | }, | |
| 1883 | - | }, | |
| 1884 | - | maxRedirects: 0, | |
| 1885 | - | }); | |
| 1886 | - | expect(resp.status()).toBe(302); | |
| 1887 | - | const location = resp.headers()['location']!; | |
| 1888 | - | releaseWithAssetsUrl = `${BASE}${location}`; | |
| 1889 | - | }); | |
| 1890 | - | ||
| 1891 | - | test('uploaded asset appears in downloads with filename and size', async () => { | |
| 1892 | - | const page = await adminCtx.newPage(); | |
| 1893 | - | try { | |
| 1894 | - | await page.goto(releaseWithAssetsUrl); | |
| 1895 | - | const names = await page.locator('.asset-name').allTextContents(); | |
| 1896 | - | expect(names.some(n => n.includes('release-asset.txt'))).toBe(true); | |
| 1897 | - | expect(await page.locator('.asset-size').isVisible()).toBe(true); | |
| 1898 | - | } finally { await page.close(); } | |
| 1899 | - | }); | |
| 1900 | - | ||
| 1901 | - | test('asset download responds with 200', async () => { | |
| 1902 | - | const page = await adminCtx.newPage(); | |
| 1903 | - | try { | |
| 1904 | - | await page.goto(releaseWithAssetsUrl); | |
| 1905 | - | const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href'); | |
| 1906 | - | const resp = await page.request.get(`${BASE}${link}`); | |
| 1907 | - | expect(resp.status()).toBe(200); | |
| 1908 | - | } finally { await page.close(); } | |
| 1909 | - | }); | |
| 1910 | - | ||
| 1911 | - | // ── Delete ────────────────────────────────────────────────────────────────── | |
| 1912 | - | ||
| 1913 | - | test('non-admin cannot delete a release', async () => { | |
| 1914 | - | const page = await aliceCtx.newPage(); | |
| 1915 | - | try { | |
| 1916 | - | const idMatch = releaseUrl.match(/\/releases\/(\d+)/); | |
| 1917 | - | const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, { | |
| 1918 | - | maxRedirects: 0, | |
| 1919 | - | }); | |
| 1920 | - | expect(resp.status()).toBe(403); | |
| 1921 | - | } finally { await page.close(); } | |
| 1922 | - | }); | |
| 1923 | - | ||
| 1924 | - | test('admin can delete a release', async () => { | |
| 1925 | - | const idMatch = releaseUrl.match(/\/releases\/(\d+)/); | |
| 1926 | - | const resp = await adminCtx.request.post( | |
| 1927 | - | `${BASE}/releases-repo/releases/${idMatch![1]}/delete`, | |
| 1928 | - | { maxRedirects: 0 }, | |
| 1929 | - | ); | |
| 1930 | - | expect(resp.status()).toBe(302); | |
| 1931 | - | // Verify it's gone from the list | |
| 1932 | - | const page = await adminCtx.newPage(); | |
| 1933 | - | try { | |
| 1934 | - | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1935 | - | const titles = await page.locator('.release-item-title').allTextContents(); | |
| 1936 | - | expect(titles.some(t => t.includes('First release'))).toBe(false); | |
| 1937 | - | } finally { await page.close(); } | |
| 1938 | - | }); | |
| 1939 | - | ||
| 1940 | - | // ── Pagination ────────────────────────────────────────────────────────────── | |
| 1941 | - | ||
| 1942 | - | test('release list shows at most 20 per page', async () => { | |
| 1943 | - | // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total | |
| 1944 | - | // regardless of which browser-based tests above succeeded | |
| 1945 | - | for (let i = 1; i <= 25; i++) { | |
| 1946 | - | await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1947 | - | multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' }, | |
| 1948 | - | maxRedirects: 0, | |
| 1949 | - | }).catch(() => {}); | |
| 1950 | - | } | |
| 1951 | - | const page = await adminCtx.newPage(); | |
| 1952 | - | try { | |
| 1953 | - | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1954 | - | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 1955 | - | } finally { await page.close(); } | |
| 1956 | - | }); | |
| 1957 | - | ||
| 1958 | - | test('pagination nav appears with more than 20 releases', async () => { | |
| 1959 | - | const page = await adminCtx.newPage(); | |
| 1960 | - | try { | |
| 1961 | - | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1962 | - | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 1963 | - | } finally { await page.close(); } | |
| 1964 | - | }); | |
| 1965 | - | ||
| 1966 | - | test('release list page 2 shows remaining releases', async () => { | |
| 1967 | - | const page = await adminCtx.newPage(); | |
| 1968 | - | try { | |
| 1969 | - | await page.goto(`${BASE}/releases-repo/releases?page=2`); | |
| 1970 | - | const count = await page.locator('.issue-item').count(); | |
| 1971 | - | expect(count).toBeGreaterThan(0); | |
| 1972 | - | expect(count).toBeLessThanOrEqual(20); | |
| 1973 | - | } finally { await page.close(); } | |
| 1974 | - | }); | |
| 1975 | - | ||
| 1976 | - | test('source archive tar.zst appears in downloads', async () => { | |
| 1977 | - | const page = await adminCtx.newPage(); | |
| 1978 | - | try { | |
| 1979 | - | await page.goto(srcReleaseUrl); | |
| 1980 | - | const assetNames = await page.locator('.asset-name').allTextContents(); | |
| 1981 | - | expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true); | |
| 1982 | - | } finally { await page.close(); } | |
| 1983 | - | }); | |
| 1984 | - | }); | |
| 1985 | - | ||
| 1986 | - | // ─── Settings ───────────────────────────────────────────────────────────────── | |
| 1987 | - | ||
| 1988 | - | describe('settings', () => { | |
| 1989 | - | let adminCtx: BrowserContext; | |
| 1990 | - | let aliceCtx: BrowserContext; | |
| 1991 | - | // Public key generated once in beforeAll, reused across SSH key tests | |
| 1992 | - | let testPubKey: string; | |
| 1993 | - | ||
| 1994 | - | beforeAll(async () => { | |
| 1995 | - | adminCtx = await loggedInContext(); | |
| 1996 | - | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1997 | - | ||
| 1998 | - | // Generate a throwaway ed25519 key for SSH key tests. | |
| 1999 | - | // Use Bun.spawn so the empty passphrase arg is passed correctly. | |
| 2000 | - | const keyPath = '/tmp/hf-e2e-sshkey'; | |
| 2001 | - | await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow(); | |
| 2002 | - | const keygen = Bun.spawn( | |
| 2003 | - | ['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'], | |
| 2004 | - | { stdout: 'ignore', stderr: 'ignore' }, | |
| 2005 | - | ); | |
| 2006 | - | await keygen.exited; | |
| 2007 | - | testPubKey = await Bun.file(`${keyPath}.pub`).text(); | |
| 2008 | - | testPubKey = testPubKey.trim(); | |
| 2009 | - | await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow(); | |
| 2010 | - | }); | |
| 2011 | - | ||
| 2012 | - | afterAll(async () => { | |
| 2013 | - | await adminCtx.close(); | |
| 2014 | - | await aliceCtx.close(); | |
| 2015 | - | }); | |
| 2016 | - | ||
| 2017 | - | test('settings page requires auth', async () => { | |
| 2018 | - | const ctx = await browser.newContext(); | |
| 2019 | - | const page = await ctx.newPage(); | |
| 2020 | - | try { | |
| 2021 | - | await page.goto(`${BASE}/settings`); | |
| 2022 | - | expect(page.url()).toContain('/login'); | |
| 2023 | - | } finally { await ctx.close(); } | |
| 2024 | - | }); | |
| 2025 | - | ||
| 2026 | - | test('settings page loads for logged-in user', async () => { | |
| 2027 | - | const page = await adminCtx.newPage(); | |
| 2028 | - | try { | |
| 2029 | - | await page.goto(`${BASE}/settings`); | |
| 2030 | - | expect(await page.locator('h1.page-title').textContent()).toBe('Settings'); | |
| 2031 | - | } finally { await page.close(); } | |
| 2032 | - | }); | |
| 2033 | - | ||
| 2034 | - | // ── Password ────────────────────────────────────────────────────────────── | |
| 2035 | - | ||
| 2036 | - | test('password change with mismatched passwords shows error', async () => { | |
| 2037 | - | const page = await aliceCtx.newPage(); | |
| 2038 | - | try { | |
| 2039 | - | await page.goto(`${BASE}/settings`); | |
| 2040 | - | await page.fill('[name=new_password]', 'newpass123'); | |
| 2041 | - | await page.fill('[name=confirm_password]', 'different456'); | |
| 2042 | - | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 2043 | - | await page.waitForURL(/\/settings/); | |
| 2044 | - | expect(page.url()).toContain('error'); | |
| 2045 | - | } finally { await page.close(); } | |
| 2046 | - | }); | |
| 2047 | - | ||
| 2048 | - | test('password change with wrong current password shows error', async () => { | |
| 2049 | - | const page = await aliceCtx.newPage(); | |
| 2050 | - | try { | |
| 2051 | - | await page.goto(`${BASE}/settings`); | |
| 2052 | - | await page.fill('[name=current_password]', 'wrongpassword'); | |
| 2053 | - | await page.fill('[name=new_password]', 'newpass123'); | |
| 2054 | - | await page.fill('[name=confirm_password]', 'newpass123'); | |
| 2055 | - | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 2056 | - | await page.waitForURL(/\/settings/); | |
| 2057 | - | expect(page.url()).toContain('error'); | |
| 2058 | - | } finally { await page.close(); } | |
| 2059 | - | }); | |
| 2060 | - | ||
| 2061 | - | test('password change too short shows error', async () => { | |
| 2062 | - | const page = await aliceCtx.newPage(); | |
| 2063 | - | try { | |
| 2064 | - | await page.goto(`${BASE}/settings`); | |
| 2065 | - | await page.fill('[name=current_password]', 'password123'); | |
| 2066 | - | await page.fill('[name=new_password]', 'short'); | |
| 2067 | - | await page.fill('[name=confirm_password]', 'short'); | |
| 2068 | - | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 2069 | - | await page.waitForURL(/\/settings/); | |
| 2070 | - | expect(page.url()).toContain('error'); | |
| 2071 | - | } finally { await page.close(); } | |
| 2072 | - | }); | |
| 2073 | - | ||
| 2074 | - | // ── SSH keys ────────────────────────────────────────────────────────────── | |
| 2075 | - | ||
| 2076 | - | test('add SSH key with unsupported key type shows error', async () => { | |
| 2077 | - | const page = await adminCtx.newPage(); | |
| 2078 | - | try { | |
| 2079 | - | await page.goto(`${BASE}/settings`); | |
| 2080 | - | await page.fill('#ssh_key_name', 'Bad key'); | |
| 2081 | - | await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test'); | |
| 2082 | - | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 2083 | - | await page.waitForURL(/\/settings/); | |
| 2084 | - | expect(page.url()).toContain('error'); | |
| 2085 | - | } finally { await page.close(); } | |
| 2086 | - | }); | |
| 2087 | - | ||
| 2088 | - | test('add valid SSH key shows success and key appears in list', async () => { | |
| 2089 | - | const page = await adminCtx.newPage(); | |
| 2090 | - | try { | |
| 2091 | - | await page.goto(`${BASE}/settings`); | |
| 2092 | - | await page.fill('#ssh_key_name', 'My Laptop'); | |
| 2093 | - | await page.fill('#ssh_public_key', testPubKey); | |
| 2094 | - | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 2095 | - | await page.waitForURL(/\/settings/); | |
| 2096 | - | expect(page.url()).toContain('success=ssh_key_added'); | |
| 2097 | - | await page.goto(`${BASE}/settings`); | |
| 2098 | - | expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop'); | |
| 2099 | - | } finally { await page.close(); } | |
| 2100 | - | }); | |
| 2101 | - | ||
| 2102 | - | test('add duplicate SSH key shows error', async () => { | |
| 2103 | - | const page = await adminCtx.newPage(); | |
| 2104 | - | try { | |
| 2105 | - | await page.goto(`${BASE}/settings`); | |
| 2106 | - | await page.fill('#ssh_key_name', 'Duplicate'); | |
| 2107 | - | await page.fill('#ssh_public_key', testPubKey); | |
| 2108 | - | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 2109 | - | await page.waitForURL(/\/settings/); | |
| 2110 | - | expect(page.url()).toContain('error'); | |
| 2111 | - | } finally { await page.close(); } | |
| 2112 | - | }); | |
| 2113 | - | ||
| 2114 | - | test('delete SSH key removes it from list', async () => { | |
| 2115 | - | const page = await adminCtx.newPage(); | |
| 2116 | - | try { | |
| 2117 | - | await page.goto(`${BASE}/settings`); | |
| 2118 | - | // Click the Remove button for the key added above | |
| 2119 | - | await page.click('form[action="/settings/ssh-keys/delete"] button'); | |
| 2120 | - | await page.waitForURL(/\/settings/); | |
| 2121 | - | expect(page.url()).toContain('success=ssh_key_deleted'); | |
| 2122 | - | await page.goto(`${BASE}/settings`); | |
| 2123 | - | expect(await page.locator('.ssh-key-name').count()).toBe(0); | |
| 2124 | - | } finally { await page.close(); } | |
| 2125 | - | }); | |
| 2126 | - | ||
| 2127 | - | // ── Admin user management ──────────────────────────────────────────────── | |
| 2128 | - | ||
| 2129 | - | test('admin can create a new user account', async () => { | |
| 2130 | - | const page = await adminCtx.newPage(); | |
| 2131 | - | try { | |
| 2132 | - | await page.goto(`${BASE}/settings`); | |
| 2133 | - | await page.fill('#new_username', 'charlie'); | |
| 2134 | - | await page.fill('#new_user_password', 'charliepw1'); | |
| 2135 | - | await page.click('form[action="/admin/users"] button[type=submit]'); | |
| 2136 | - | await page.waitForURL(/\/settings/); | |
| 2137 | - | expect(page.url()).toContain('success=user_created'); | |
| 2138 | - | } finally { await page.close(); } | |
| 2139 | - | }); | |
| 2140 | - | ||
| 2141 | - | test('admin cannot create duplicate username', async () => { | |
| 2142 | - | const page = await adminCtx.newPage(); | |
| 2143 | - | try { | |
| 2144 | - | await page.goto(`${BASE}/settings`); | |
| 2145 | - | await page.fill('#new_username', 'charlie'); | |
| 2146 | - | await page.fill('#new_user_password', 'charliepw1'); | |
| 2147 | - | await page.click('form[action="/admin/users"] button[type=submit]'); | |
| 2148 | - | await page.waitForURL(/\/settings/); | |
| 2149 | - | expect(page.url()).toContain('error'); | |
| 2150 | - | } finally { await page.close(); } | |
| 2151 | - | }); | |
| 2152 | - | ||
| 2153 | - | test('admin cannot create user with invalid username characters', async () => { | |
| 2154 | - | const resp = await adminCtx.request.post(`${BASE}/admin/users`, { | |
| 2155 | - | form: { username: 'bad user!', password: 'password123' }, | |
| 2156 | - | maxRedirects: 0, | |
| 2157 | - | }); | |
| 2158 | - | expect(resp.status()).toBe(302); | |
| 2159 | - | const location = resp.headers()['location'] ?? ''; | |
| 2160 | - | expect(location).toContain('error'); | |
| 2161 | - | }); | |
| 2162 | - | ||
| 2163 | - | test('non-admin gets 403 when creating user', async () => { | |
| 2164 | - | const resp = await aliceCtx.request.post(`${BASE}/admin/users`, { | |
| 2165 | - | form: { username: 'hacker', password: 'password123' }, | |
| 2166 | - | maxRedirects: 0, | |
| 2167 | - | }); | |
| 2168 | - | expect(resp.status()).toBe(403); | |
| 2169 | - | }); | |
| 2170 | - | ||
| 2171 | - | test('admin can delete user account', async () => { | |
| 2172 | - | const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { | |
| 2173 | - | form: { username: 'charlie' }, | |
| 2174 | - | maxRedirects: 0, | |
| 2175 | - | }); | |
| 2176 | - | expect(resp.status()).toBe(302); | |
| 2177 | - | expect(resp.headers()['location']).toContain('success=user_deleted'); | |
| 2178 | - | }); | |
| 2179 | - | ||
| 2180 | - | test('admin cannot delete the admin account', async () => { | |
| 2181 | - | const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { | |
| 2182 | - | form: { username: 'admin' }, | |
| 2183 | - | maxRedirects: 0, | |
| 2184 | - | }); | |
| 2185 | - | expect(resp.status()).toBe(302); | |
| 2186 | - | expect(resp.headers()['location']).toContain('error'); | |
| 2187 | - | }); | |
| 2188 | - | ||
| 2189 | - | test('settings page has no git identity section', async () => { | |
| 2190 | - | const page = await adminCtx.newPage(); | |
| 2191 | - | try { | |
| 2192 | - | await page.goto(`${BASE}/settings`); | |
| 2193 | - | expect(await page.locator('text=Git Identity').count()).toBe(0); | |
| 2194 | - | expect(await page.locator('[name=git_name]').count()).toBe(0); | |
| 2195 | - | expect(await page.locator('[name=git_email]').count()).toBe(0); | |
| 2196 | - | } finally { await page.close(); } | |
| 2197 | - | }); | |
| 2198 | - | ||
| 2199 | - | test('git identity route no longer exists', async () => { | |
| 2200 | - | const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, { | |
| 2201 | - | form: { git_name: 'Test', git_email: 'test@example.com' }, | |
| 2202 | - | maxRedirects: 0, | |
| 2203 | - | }); | |
| 2204 | - | expect(resp.status()).toBe(404); | |
| 2205 | - | }); | |
| 2206 | - | }); | |
| 2207 | - | ||
| 2208 | - | // ─── Repository deletion ────────────────────────────────────────────────────── | |
| 2209 | - | ||
| 2210 | - | describe('repository deletion', () => { | |
| 2211 | - | let adminCtx: BrowserContext; | |
| 2212 | - | ||
| 2213 | - | beforeAll(async () => { | |
| 2214 | - | adminCtx = await loggedInContext(); | |
| 2215 | - | ||
| 2216 | - | // Create a repo to delete | |
| 2217 | - | const page = await adminCtx.newPage(); | |
| 2218 | - | try { | |
| 2219 | - | await page.goto(`${BASE}/new`); | |
| 2220 | - | await page.fill('[name=name]', 'deleteme-repo'); | |
| 2221 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 2222 | - | await page.waitForURL(`${BASE}/deleteme-repo`); | |
| 2223 | - | } finally { await page.close(); } | |
| 2224 | - | }); | |
| 2225 | - | ||
| 2226 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 2227 | - | ||
| 2228 | - | test('admin can delete repository', async () => { | |
| 2229 | - | const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, { | |
| 2230 | - | maxRedirects: 0, | |
| 2231 | - | }); | |
| 2232 | - | expect(resp.status()).toBe(302); | |
| 2233 | - | expect(resp.headers()['location']).toBe('/'); | |
| 2234 | - | }); | |
| 2235 | - | ||
| 2236 | - | test('deleted repository returns 404', async () => { | |
| 2237 | - | const page = await adminCtx.newPage(); | |
| 2238 | - | try { | |
| 2239 | - | const resp = await page.request.get(`${BASE}/deleteme-repo`); | |
| 2240 | - | expect(resp.status()).toBe(404); | |
| 2241 | - | } finally { await page.close(); } | |
| 2242 | - | }); | |
| 2243 | - | ||
| 2244 | - | test('deleted repository no longer appears in list', async () => { | |
| 2245 | - | const page = await adminCtx.newPage(); | |
| 2246 | - | try { | |
| 2247 | - | await page.goto(BASE); | |
| 2248 | - | expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo'); | |
| 2249 | - | } finally { await page.close(); } | |
| 2250 | - | }); | |
| 2251 | - | ||
| 2252 | - | test('non-admin cannot delete repository', async () => { | |
| 2253 | - | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 2254 | - | const page = await aliceCtx.newPage(); | |
| 2255 | - | try { | |
| 2256 | - | const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, { | |
| 2257 | - | maxRedirects: 0, | |
| 2258 | - | }); | |
| 2259 | - | expect(resp.status()).toBe(403); | |
| 2260 | - | } finally { | |
| 2261 | - | await page.close(); | |
| 2262 | - | await aliceCtx.close(); | |
| 2263 | - | } | |
| 2264 | - | }); | |
| 2265 | - | }); | |
| 2266 | - | ||
| 2267 | - | // ─── 404 handling ───────────────────────────────────────────────────────────── | |
| 2268 | - | ||
| 2269 | - | describe('404 handling', () => { | |
| 2270 | - | let adminCtx: BrowserContext; | |
| 2271 | - | ||
| 2272 | - | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 2273 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 2274 | - | ||
| 2275 | - | test('non-existent repository returns 404', async () => { | |
| 2276 | - | const page = await adminCtx.newPage(); | |
| 2277 | - | try { | |
| 2278 | - | const resp = await page.request.get(`${BASE}/no-such-repo`); | |
| 2279 | - | expect(resp.status()).toBe(404); | |
| 2280 | - | } finally { await page.close(); } | |
| 2281 | - | }); | |
| 2282 | - | ||
| 2283 | - | test('non-existent issue returns 404', async () => { | |
| 2284 | - | const page = await adminCtx.newPage(); | |
| 2285 | - | try { | |
| 2286 | - | const resp = await page.request.get(`${BASE}/my-repo/issues/99999`); | |
| 2287 | - | expect(resp.status()).toBe(404); | |
| 2288 | - | } finally { await page.close(); } | |
| 2289 | - | }); | |
| 2290 | - | ||
| 2291 | - | test('non-existent commit returns 404', async () => { | |
| 2292 | - | const page = await adminCtx.newPage(); | |
| 2293 | - | try { | |
| 2294 | - | const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`); | |
| 2295 | - | expect(resp.status()).toBe(404); | |
| 2296 | - | } finally { await page.close(); } | |
| 2297 | - | }); | |
| 2298 | - | ||
| 2299 | - | test('non-existent file blob returns 404', async () => { | |
| 2300 | - | const page = await adminCtx.newPage(); | |
| 2301 | - | try { | |
| 2302 | - | const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`); | |
| 2303 | - | expect(resp.status()).toBe(404); | |
| 2304 | - | } finally { await page.close(); } | |
| 2305 | - | }); | |
| 2306 | - | ||
| 2307 | - | test('non-existent patch returns 404', async () => { | |
| 2308 | - | const page = await adminCtx.newPage(); | |
| 2309 | - | try { | |
| 2310 | - | const resp = await page.request.get(`${BASE}/my-repo/patches/99999`); | |
| 2311 | - | expect(resp.status()).toBe(404); | |
| 2312 | - | } finally { await page.close(); } | |
| 2313 | - | }); | |
| 2314 | - | ||
| 2315 | - | test('non-existent release returns 404', async () => { | |
| 2316 | - | const page = await adminCtx.newPage(); | |
| 2317 | - | try { | |
| 2318 | - | const resp = await page.request.get(`${BASE}/my-repo/releases/99999`); | |
| 2319 | - | expect(resp.status()).toBe(404); | |
| 2320 | - | } finally { await page.close(); } | |
| 2321 | - | }); | |
| 2322 | - | }); | |
| 2323 | - | ||
| 2324 | - | // ─── Issue editing and deletion ─────────────────────────────────────────────── | |
| 2325 | - | ||
| 2326 | - | describe('issue editing', () => { | |
| 2327 | - | let adminCtx: BrowserContext; | |
| 2328 | - | let aliceCtx: BrowserContext; | |
| 2329 | - | let issueUrl: string; | |
| 2330 | - | ||
| 2331 | - | beforeAll(async () => { | |
| 2332 | - | adminCtx = await loggedInContext(); | |
| 2333 | - | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 2334 | - | ||
| 2335 | - | // Create an issue to edit | |
| 2336 | - | const page = await adminCtx.newPage(); | |
| 2337 | - | try { | |
| 2338 | - | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 2339 | - | await page.fill('[name=title]', 'Issue to edit'); | |
| 2340 | - | await page.fill('[name=body]', 'Original body.'); | |
| 2341 | - | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 2342 | - | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 2343 | - | issueUrl = page.url(); | |
| 2344 | - | } finally { await page.close(); } | |
| 2345 | - | }); | |
| 2346 | - | ||
| 2347 | - | afterAll(async () => { | |
| 2348 | - | await adminCtx.close(); | |
| 2349 | - | await aliceCtx.close(); | |
| 2350 | - | }); | |
| 2351 | - | ||
| 2352 | - | test('author can edit issue title and body', async () => { | |
| 2353 | - | const page = await adminCtx.newPage(); | |
| 2354 | - | try { | |
| 2355 | - | await page.goto(issueUrl); | |
| 2356 | - | // Edit title via title form | |
| 2357 | - | await page.click('details.title-edit-details summary'); | |
| 2358 | - | await page.fill('.title-edit-form-area [name=title]', 'Edited issue title'); | |
| 2359 | - | await page.click('.title-edit-form-area [type=submit]'); | |
| 2360 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 2361 | - | expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title'); | |
| 2362 | - | // Edit body via inline form | |
| 2363 | - | await page.click('.timeline-author .inline-edit-details summary'); | |
| 2364 | - | await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.'); | |
| 2365 | - | await page.click('.inline-edit-form-area [type=submit]'); | |
| 2366 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 2367 | - | } finally { await page.close(); } | |
| 2368 | - | }); | |
| 2369 | - | ||
| 2370 | - | test('edited marker appears after editing', async () => { | |
| 2371 | - | const page = await adminCtx.newPage(); | |
| 2372 | - | try { | |
| 2373 | - | await page.goto(issueUrl); | |
| 2374 | - | expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0); | |
| 2375 | - | } finally { await page.close(); } | |
| 2376 | - | }); | |
| 2377 | - | ||
| 2378 | - | test('non-author non-admin cannot edit issue', async () => { | |
| 2379 | - | const page = await aliceCtx.newPage(); | |
| 2380 | - | try { | |
| 2381 | - | const issueNum = issueUrl.split('/issues/')[1]; | |
| 2382 | - | const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, { | |
| 2383 | - | form: { title: 'Hacked title', edit_body: '' }, | |
| 2384 | - | maxRedirects: 0, | |
| 2385 | - | }); | |
| 2386 | - | expect(resp.status()).toBe(403); | |
| 2387 | - | } finally { await page.close(); } | |
| 2388 | - | }); | |
| 2389 | - | ||
| 2390 | - | test('author can edit issue comment', async () => { | |
| 2391 | - | const page = await adminCtx.newPage(); | |
| 2392 | - | try { | |
| 2393 | - | await page.goto(issueUrl); | |
| 2394 | - | // Add a comment first | |
| 2395 | - | await page.fill('textarea[name=body]', 'Comment to edit.'); | |
| 2396 | - | await page.click('form[action*="/comments"] button[type=submit]'); | |
| 2397 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 2398 | - | ||
| 2399 | - | // Edit the comment | |
| 2400 | - | const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' }); | |
| 2401 | - | await commentItem.locator('.inline-edit-details summary').click(); | |
| 2402 | - | await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.'); | |
| 2403 | - | await commentItem.locator('.inline-edit-form-area [type=submit]').click(); | |
| 2404 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 2405 | - | expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.'); | |
| 2406 | - | } finally { await page.close(); } | |
| 2407 | - | }); | |
| 2408 | - | ||
| 2409 | - | test('non-admin user can create an issue', async () => { | |
| 2410 | - | const page = await aliceCtx.newPage(); | |
| 2411 | - | try { | |
| 2412 | - | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 2413 | - | await page.fill('[name=title]', "Alice's issue"); | |
| 2414 | - | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 2415 | - | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 2416 | - | expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue"); | |
| 2417 | - | } finally { await page.close(); } | |
| 2418 | - | }); | |
| 2419 | - | ||
| 2420 | - | test('non-admin cannot comment on a closed issue', async () => { | |
| 2421 | - | // Close the issue as admin first | |
| 2422 | - | const issueNum = issueUrl.split('/issues/')[1]; | |
| 2423 | - | await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {}); | |
| 2424 | - | ||
| 2425 | - | const page = await aliceCtx.newPage(); | |
| 2426 | - | try { | |
| 2427 | - | const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, { | |
| 2428 | - | form: { body: 'comment on closed issue' }, | |
| 2429 | - | maxRedirects: 0, | |
| 2430 | - | }); | |
| 2431 | - | // Non-admin gets redirected (silently ignored), not an error | |
| 2432 | - | expect(resp.status()).toBe(302); | |
| 2433 | - | // The comment should NOT appear | |
| 2434 | - | await page.goto(issueUrl); | |
| 2435 | - | const bodies = await page.locator('.timeline-body').allTextContents(); | |
| 2436 | - | expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true); | |
| 2437 | - | } finally { await page.close(); } | |
| 2438 | - | }); | |
| 2439 | - | ||
| 2440 | - | test('admin can delete issue', async () => { | |
| 2441 | - | const issueNum = issueUrl.split('/issues/')[1]; | |
| 2442 | - | const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, { | |
| 2443 | - | maxRedirects: 0, | |
| 2444 | - | }); | |
| 2445 | - | expect(resp.status()).toBe(302); | |
| 2446 | - | // Issue should be gone | |
| 2447 | - | const page = await adminCtx.newPage(); | |
| 2448 | - | try { | |
| 2449 | - | const checkResp = await page.request.get(issueUrl); | |
| 2450 | - | expect(checkResp.status()).toBe(404); | |
| 2451 | - | } finally { await page.close(); } | |
| 2452 | - | }); | |
| 2453 | - | }); | |
| 2454 | - | ||
| 2455 | - | // ─── Repository description update ─────────────────────────────────────────── | |
| 2456 | - | ||
| 2457 | - | describe('repo description', () => { | |
| 2458 | - | let adminCtx: BrowserContext; | |
| 2459 | - | ||
| 2460 | - | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 2461 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 2462 | - | ||
| 2463 | - | test('updating repo description is reflected on list page', async () => { | |
| 2464 | - | const page = await adminCtx.newPage(); | |
| 2465 | - | try { | |
| 2466 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 2467 | - | await page.fill('[name=description]', 'A freshly updated description'); | |
| 2468 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2469 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2470 | - | ||
| 2471 | - | await page.goto(BASE); | |
| 2472 | - | const desc = await page.locator('.repo-description').allTextContents(); | |
| 2473 | - | expect(desc.some(d => d.includes('freshly updated description'))).toBe(true); | |
| 2474 | - | } finally { await page.close(); } | |
| 2475 | - | }); | |
| 2476 | - | }); | |
| 2477 | - | ||
| 2478 | - | // ─── Issue and patch templates ──────────────────────────────────────────────── | |
| 2479 | - | ||
| 2480 | - | describe('issue and patch templates', () => { | |
| 2481 | - | let adminCtx: BrowserContext; | |
| 2482 | - | ||
| 2483 | - | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 2484 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 2485 | - | ||
| 2486 | - | test('issue template can be saved and is prefilled on new issue form', async () => { | |
| 2487 | - | const page = await adminCtx.newPage(); | |
| 2488 | - | try { | |
| 2489 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 2490 | - | await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior'); | |
| 2491 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2492 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2493 | - | ||
| 2494 | - | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 2495 | - | const body = await page.locator('[name=body]').inputValue(); | |
| 2496 | - | expect(body).toContain('## Steps to reproduce'); | |
| 2497 | - | expect(body).toContain('## Expected behavior'); | |
| 2498 | - | } finally { await page.close(); } | |
| 2499 | - | }); | |
| 2500 | - | ||
| 2501 | - | test('patch template can be saved and is prefilled on new patch form', async () => { | |
| 2502 | - | const page = await adminCtx.newPage(); | |
| 2503 | - | try { | |
| 2504 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 2505 | - | await page.fill('[name=patch_template]', '## Summary\n\n## Testing'); | |
| 2506 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2507 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2508 | - | ||
| 2509 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 2510 | - | const desc = await page.locator('[name=description]').inputValue(); | |
| 2511 | - | expect(desc).toContain('## Summary'); | |
| 2512 | - | expect(desc).toContain('## Testing'); | |
| 2513 | - | } finally { await page.close(); } | |
| 2514 | - | }); | |
| 2515 | - | ||
| 2516 | - | test('clearing the issue template removes prefill', async () => { | |
| 2517 | - | const page = await adminCtx.newPage(); | |
| 2518 | - | try { | |
| 2519 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 2520 | - | await page.fill('[name=issue_template]', ''); | |
| 2521 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2522 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2523 | - | ||
| 2524 | - | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 2525 | - | const body = await page.locator('[name=body]').inputValue(); | |
| 2526 | - | expect(body).toBe(''); | |
| 2527 | - | } finally { await page.close(); } | |
| 2528 | - | }); | |
| 2529 | - | ||
| 2530 | - | test('clearing the patch template removes prefill', async () => { | |
| 2531 | - | const page = await adminCtx.newPage(); | |
| 2532 | - | try { | |
| 2533 | - | await page.goto(`${BASE}/my-repo/settings`); | |
| 2534 | - | await page.fill('[name=patch_template]', ''); | |
| 2535 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2536 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2537 | - | ||
| 2538 | - | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 2539 | - | const desc = await page.locator('[name=description]').inputValue(); | |
| 2540 | - | expect(desc).toBe(''); | |
| 2541 | - | } finally { await page.close(); } | |
| 2542 | - | }); | |
| 2543 | - | }); | |
| 2544 | - | ||
| 2545 | - | // ─── Commit signing ─────────────────────────────────────────────────────────── | |
| 2546 | - | // Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo. | |
| 2547 | - | ||
| 2548 | - | describe('commit signing', () => { | |
| 2549 | - | let adminCtx: BrowserContext; | |
| 2550 | - | ||
| 2551 | - | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 2552 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 2553 | - | ||
| 2554 | - | test('allowed_signers file is generated at startup', () => { | |
| 2555 | - | const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`; | |
| 2556 | - | expect(existsSync(allowedSignersPath)).toBe(true); | |
| 2557 | - | const content = readFileSync(allowedSignersPath, 'utf8'); | |
| 2558 | - | expect(content).toContain('namespaces="git"'); | |
| 2559 | - | expect(content).toContain('ssh-ed25519'); | |
| 2560 | - | }); | |
| 2561 | - | ||
| 2562 | - | test('merged commit has a gpgsig header', async () => { | |
| 2563 | - | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 2564 | - | // Find the patch commit specifically by subject | |
| 2565 | - | const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim(); | |
| 2566 | - | expect(hash).toBeTruthy(); | |
| 2567 | - | const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); | |
| 2568 | - | expect(obj).toContain('gpgsig'); | |
| 2569 | - | }); | |
| 2570 | - | ||
| 2571 | - | test('unsigned commits have no gpgsig header', async () => { | |
| 2572 | - | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 2573 | - | // Initial commit was created by seedRepo (plain git commit, not hearthforge) | |
| 2574 | - | const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim(); | |
| 2575 | - | expect(hash).toBeTruthy(); | |
| 2576 | - | const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); | |
| 2577 | - | expect(obj).not.toContain('gpgsig'); | |
| 2578 | - | }); | |
| 2579 | - | ||
| 2580 | - | test('commit log shows verified badge on signed commit', async () => { | |
| 2581 | - | const page = await adminCtx.newPage(); | |
| 2582 | - | try { | |
| 2583 | - | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 2584 | - | // Find the commit-item for the merged patch by subject text | |
| 2585 | - | const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' }); | |
| 2586 | - | expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 2587 | - | } finally { await page.close(); } | |
| 2588 | - | }); | |
| 2589 | - | ||
| 2590 | - | test('commit log shows no sig badge on unsigned commit', async () => { | |
| 2591 | - | const page = await adminCtx.newPage(); | |
| 2592 | - | try { | |
| 2593 | - | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 2594 | - | // Initial commit was not signed via hearthforge | |
| 2595 | - | const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' }); | |
| 2596 | - | expect(await initialItem.locator('.sig-badge').count()).toBe(0); | |
| 2597 | - | } finally { await page.close(); } | |
| 2598 | - | }); | |
| 2599 | - | ||
| 2600 | - | test('commit detail shows verified signature row for signed commit', async () => { | |
| 2601 | - | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 2602 | - | const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim(); | |
| 2603 | - | const page = await adminCtx.newPage(); | |
| 2604 | - | try { | |
| 2605 | - | await page.goto(`${BASE}/my-repo/commit/${hash}`); | |
| 2606 | - | const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' }); | |
| 2607 | - | expect(await sigRow.isVisible()).toBe(true); | |
| 2608 | - | expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 2609 | - | } finally { await page.close(); } | |
| 2610 | - | }); | |
| 2611 | - | ||
| 2612 | - | test('commit detail shows no signature row for unsigned commit', async () => { | |
| 2613 | - | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 2614 | - | const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim(); | |
| 2615 | - | const page = await adminCtx.newPage(); | |
| 2616 | - | try { | |
| 2617 | - | await page.goto(`${BASE}/my-repo/commit/${hash}`); | |
| 2618 | - | const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' }); | |
| 2619 | - | expect(await sigRow.count()).toBe(0); | |
| 2620 | - | } finally { await page.close(); } | |
| 2621 | - | }); | |
| 2622 | - | }); | |
| 2623 | - | ||
| 2624 | - | // ─── Repo sorting and pinning ───────────────────────────────────────────────── | |
| 2625 | - | ||
| 2626 | - | describe('repo sorting and pinning', () => { | |
| 2627 | - | let adminCtx: BrowserContext; | |
| 2628 | - | ||
| 2629 | - | beforeAll(async () => { | |
| 2630 | - | adminCtx = await loggedInContext(); | |
| 2631 | - | // Create two repos with predictable names: sort-aaa (created first/older), | |
| 2632 | - | // sort-zzz (created second/newer). This lets us verify both name order and | |
| 2633 | - | // creation-time order independently. | |
| 2634 | - | const page = await adminCtx.newPage(); | |
| 2635 | - | try { | |
| 2636 | - | await page.goto(`${BASE}/new`); | |
| 2637 | - | await page.fill('[name=name]', 'sort-aaa'); | |
| 2638 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 2639 | - | await page.waitForURL(`${BASE}/sort-aaa`); | |
| 2640 | - | ||
| 2641 | - | await page.goto(`${BASE}/new`); | |
| 2642 | - | await page.fill('[name=name]', 'sort-zzz'); | |
| 2643 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 2644 | - | await page.waitForURL(`${BASE}/sort-zzz`); | |
| 2645 | - | } finally { await page.close(); } | |
| 2646 | - | }); | |
| 2647 | - | ||
| 2648 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 2649 | - | ||
| 2650 | - | test('sort dropdown is visible on repo list page', async () => { | |
| 2651 | - | const page = await adminCtx.newPage(); | |
| 2652 | - | try { | |
| 2653 | - | await page.goto(BASE); | |
| 2654 | - | const options = await page.locator('.repo-sort-select option').allTextContents(); | |
| 2655 | - | expect(options.some(t => t.includes('Newest'))).toBe(true); | |
| 2656 | - | expect(options.some(t => t.includes('Name'))).toBe(true); | |
| 2657 | - | } finally { await page.close(); } | |
| 2658 | - | }); | |
| 2659 | - | ||
| 2660 | - | test('newest option is selected by default', async () => { | |
| 2661 | - | // Use a fresh context to ensure no repo_sort cookie is set. | |
| 2662 | - | const ctx = await browser.newContext(); | |
| 2663 | - | const page = await ctx.newPage(); | |
| 2664 | - | try { | |
| 2665 | - | await login(page, 'admin', ADMIN_PASS); | |
| 2666 | - | await page.goto(BASE); | |
| 2667 | - | expect(await page.locator('.repo-sort-select').inputValue()).toBe('created'); | |
| 2668 | - | } finally { await ctx.close(); } | |
| 2669 | - | }); | |
| 2670 | - | ||
| 2671 | - | test('Go button is hidden with JS and works without JS', async () => { | |
| 2672 | - | const ctx = await browser.newContext({ javaScriptEnabled: false }); | |
| 2673 | - | const page = await ctx.newPage(); | |
| 2674 | - | try { | |
| 2675 | - | await login(page, 'admin', ADMIN_PASS); | |
| 2676 | - | await page.goto(BASE); | |
| 2677 | - | // Go button visible without JS | |
| 2678 | - | expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true); | |
| 2679 | - | // Select name sort and submit via Go button | |
| 2680 | - | await page.locator('.repo-sort-select').selectOption('name'); | |
| 2681 | - | await page.locator('form[action="/sort"] button[type=submit]').click(); | |
| 2682 | - | await page.waitForURL(BASE + '/'); | |
| 2683 | - | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 2684 | - | } finally { await ctx.close(); } | |
| 2685 | - | }); | |
| 2686 | - | ||
| 2687 | - | test('selecting name sort sets cookie and persists on next visit', async () => { | |
| 2688 | - | const page = await adminCtx.newPage(); | |
| 2689 | - | try { | |
| 2690 | - | await page.goto(BASE); | |
| 2691 | - | await Promise.all([ | |
| 2692 | - | page.waitForURL(BASE + '/'), | |
| 2693 | - | page.locator('.repo-sort-select').selectOption('name'), | |
| 2694 | - | ]); | |
| 2695 | - | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 2696 | - | // Navigate away and back to confirm cookie persists | |
| 2697 | - | await page.goto(`${BASE}/my-repo`); | |
| 2698 | - | await page.goto(BASE); | |
| 2699 | - | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 2700 | - | } finally { | |
| 2701 | - | // Reset cookie so subsequent tests start from the default sort. | |
| 2702 | - | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 2703 | - | await page.close(); | |
| 2704 | - | } | |
| 2705 | - | }); | |
| 2706 | - | ||
| 2707 | - | test('default sort shows newer repo before older repo', async () => { | |
| 2708 | - | const page = await adminCtx.newPage(); | |
| 2709 | - | try { | |
| 2710 | - | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 2711 | - | await page.goto(`${BASE}/?q=sort-`); | |
| 2712 | - | const names = await page.locator('.repo-name').allTextContents(); | |
| 2713 | - | expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa')); | |
| 2714 | - | } finally { await page.close(); } | |
| 2715 | - | }); | |
| 2716 | - | ||
| 2717 | - | test('name sort shows repos in alphabetical order', async () => { | |
| 2718 | - | const page = await adminCtx.newPage(); | |
| 2719 | - | try { | |
| 2720 | - | await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]); | |
| 2721 | - | await page.goto(`${BASE}/?q=sort-`); | |
| 2722 | - | const names = await page.locator('.repo-name').allTextContents(); | |
| 2723 | - | expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz')); | |
| 2724 | - | } finally { | |
| 2725 | - | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 2726 | - | await page.close(); | |
| 2727 | - | } | |
| 2728 | - | }); | |
| 2729 | - | ||
| 2730 | - | test('pinning a repo shows pinned badge on list page', async () => { | |
| 2731 | - | const page = await adminCtx.newPage(); | |
| 2732 | - | try { | |
| 2733 | - | await page.goto(`${BASE}/sort-aaa/settings`); | |
| 2734 | - | await page.check('[name=is_pinned]'); | |
| 2735 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2736 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2737 | - | ||
| 2738 | - | await page.goto(BASE); | |
| 2739 | - | const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' }); | |
| 2740 | - | expect(await card.locator('.badge-pinned').isVisible()).toBe(true); | |
| 2741 | - | } finally { await page.close(); } | |
| 2742 | - | }); | |
| 2743 | - | ||
| 2744 | - | test('pinned repo appears before unpinned repos regardless of creation order', async () => { | |
| 2745 | - | const page = await adminCtx.newPage(); | |
| 2746 | - | try { | |
| 2747 | - | // sort-aaa is pinned; default (newest) sort would normally show sort-zzz | |
| 2748 | - | // first since it's newer — but pinned repos float to the top. | |
| 2749 | - | await page.goto(`${BASE}/?q=sort-`); | |
| 2750 | - | const names = await page.locator('.repo-name').allTextContents(); | |
| 2751 | - | expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz')); | |
| 2752 | - | } finally { await page.close(); } | |
| 2753 | - | }); | |
| 2754 | - | ||
| 2755 | - | test('unpinning a repo removes the pinned badge', async () => { | |
| 2756 | - | const page = await adminCtx.newPage(); | |
| 2757 | - | try { | |
| 2758 | - | await page.goto(`${BASE}/sort-aaa/settings`); | |
| 2759 | - | await page.uncheck('[name=is_pinned]'); | |
| 2760 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2761 | - | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2762 | - | ||
| 2763 | - | await page.goto(BASE); | |
| 2764 | - | const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' }); | |
| 2765 | - | expect(await card.locator('.badge-pinned').count()).toBe(0); | |
| 2766 | - | } finally { await page.close(); } | |
| 2767 | - | }); | |
| 2768 | - | }); | |
| 2769 | - | ||
| 2770 | - | // ─── File editing ───────────────────────────────────────────────────────────── | |
| 2771 | - | ||
| 2772 | - | describe('file editing', () => { | |
| 2773 | - | let adminCtx: BrowserContext; | |
| 2774 | - | ||
| 2775 | - | beforeAll(async () => { | |
| 2776 | - | adminCtx = await loggedInContext(); | |
| 2777 | - | // Create a dedicated repo so edits don't interfere with other tests | |
| 2778 | - | const page = await adminCtx.newPage(); | |
| 2779 | - | try { | |
| 2780 | - | await page.goto(`${BASE}/new`); | |
| 2781 | - | await page.fill('[name=name]', 'edit-repo'); | |
| 2782 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 2783 | - | await page.waitForURL(`${BASE}/edit-repo`); | |
| 2784 | - | } finally { await page.close(); } | |
| 2785 | - | await seedRepo('edit-repo'); | |
| 2786 | - | }); | |
| 2787 | - | ||
| 2788 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 2789 | - | ||
| 2790 | - | test('Edit button appears on text file blob when viewing a branch as admin', async () => { | |
| 2791 | - | const page = await adminCtx.newPage(); | |
| 2792 | - | try { | |
| 2793 | - | await page.goto(`${BASE}/edit-repo/blob/main/index.js`); | |
| 2794 | - | const editBtn = page.locator('a[href*="/edit/main/index.js"]'); | |
| 2795 | - | expect(await editBtn.isVisible()).toBe(true); | |
| 2796 | - | expect(await editBtn.textContent()).toBe('Edit'); | |
| 2797 | - | } finally { await page.close(); } | |
| 2798 | - | }); | |
| 2799 | - | ||
| 2800 | - | test('Edit button does not appear when viewing a commit SHA', async () => { | |
| 2801 | - | const sha = await getHeadCommit('edit-repo'); | |
| 2802 | - | const page = await adminCtx.newPage(); | |
| 2803 | - | try { | |
| 2804 | - | await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`); | |
| 2805 | - | expect(await page.locator('a[href*="/edit/"]').count()).toBe(0); | |
| 2806 | - | } finally { await page.close(); } | |
| 2807 | - | }); | |
| 2808 | - | ||
| 2809 | - | test('Edit button does not appear for unauthenticated visitors', async () => { | |
| 2810 | - | const ctx = await browser.newContext(); | |
| 2811 | - | const page = await ctx.newPage(); | |
| 2812 | - | try { | |
| 2813 | - | await page.goto(`${BASE}/edit-repo/blob/main/index.js`); | |
| 2814 | - | expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0); | |
| 2815 | - | } finally { | |
| 2816 | - | await page.close(); | |
| 2817 | - | await ctx.close(); | |
| 2818 | - | } | |
| 2819 | - | }); | |
| 2820 | - | ||
| 2821 | - | test('edit page loads with file content pre-filled', async () => { | |
| 2822 | - | const page = await adminCtx.newPage(); | |
| 2823 | - | try { | |
| 2824 | - | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 2825 | - | expect(await page.locator('.file-blob-name').textContent()).toBe('index.js'); | |
| 2826 | - | const content = await page.locator('textarea[name=content]').inputValue(); | |
| 2827 | - | expect(content).toContain('hello'); | |
| 2828 | - | const msg = await page.locator('textarea[name=message]').inputValue(); | |
| 2829 | - | expect(msg).toBe('Edited index.js'); | |
| 2830 | - | } finally { await page.close(); } | |
| 2831 | - | }); | |
| 2832 | - | ||
| 2833 | - | test('edit page shows which branch will be committed to', async () => { | |
| 2834 | - | const page = await adminCtx.newPage(); | |
| 2835 | - | try { | |
| 2836 | - | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 2837 | - | expect(await page.content()).toContain('main'); | |
| 2838 | - | } finally { await page.close(); } | |
| 2839 | - | }); | |
| 2840 | - | ||
| 2841 | - | test('edit page returns 404 for non-branch ref', async () => { | |
| 2842 | - | const sha = await getHeadCommit('edit-repo'); | |
| 2843 | - | const page = await adminCtx.newPage(); | |
| 2844 | - | try { | |
| 2845 | - | const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`); | |
| 2846 | - | expect(resp.status()).toBe(404); | |
| 2847 | - | } finally { await page.close(); } | |
| 2848 | - | }); | |
| 2849 | - | ||
| 2850 | - | test('submitting edit creates a new commit and redirects to blob view', async () => { | |
| 2851 | - | const page = await adminCtx.newPage(); | |
| 2852 | - | try { | |
| 2853 | - | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 2854 | - | await page.fill('textarea[name=content]', 'console.log("edited");\n'); | |
| 2855 | - | await page.fill('textarea[name=message]', 'Update index.js via web editor'); | |
| 2856 | - | await page.locator('.form-actions button[type=submit]').click(); | |
| 2857 | - | await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/); | |
| 2858 | - | // The commit detail view should show the commit message | |
| 2859 | - | expect(await page.content()).toContain('Update index.js via web editor'); | |
| 2860 | - | } finally { await page.close(); } | |
| 2861 | - | }); | |
| 2862 | - | ||
| 2863 | - | test('edit commit has a gpgsig header (is signed)', async () => { | |
| 2864 | - | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`; | |
| 2865 | - | const hash = ( | |
| 2866 | - | await $`git -C ${repoDir} log --format=%H --grep="Update index.js via web editor" -1`.quiet() | |
| 2867 | - | ).text().trim(); | |
| 2868 | - | expect(hash).toBeTruthy(); | |
| 2869 | - | const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); | |
| 2870 | - | expect(obj).toContain('gpgsig'); | |
| 2871 | - | }); | |
| 2872 | - | ||
| 2873 | - | test('edit commit shows verified badge in commit log', async () => { | |
| 2874 | - | const page = await adminCtx.newPage(); | |
| 2875 | - | try { | |
| 2876 | - | await page.goto(`${BASE}/edit-repo/commits/main`); | |
| 2877 | - | const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' }); | |
| 2878 | - | expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 2879 | - | } finally { await page.close(); } | |
| 2880 | - | }); | |
| 2881 | - | ||
| 2882 | - | test('GET edit page returns 404 for non-branch ref', async () => { | |
| 2883 | - | const sha = await getHeadCommit('edit-repo'); | |
| 2884 | - | const page = await adminCtx.newPage(); | |
| 2885 | - | try { | |
| 2886 | - | const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`); | |
| 2887 | - | expect(resp.status()).toBe(404); | |
| 2888 | - | } finally { await page.close(); } | |
| 2889 | - | }); | |
| 2890 | - | }); | |
| 2891 | - | ||
| 2892 | - | // ─── Labels ─────────────────────────────────────────────────────────────────── | |
| 2893 | - | ||
| 2894 | - | describe('labels', () => { | |
| 2895 | - | let adminCtx: BrowserContext; | |
| 2896 | - | let issueUrl: string; | |
| 2897 | - | let patchUrl: string; | |
| 2898 | - | ||
| 2899 | - | const VALID_PATCH = [ | |
| 2900 | - | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 2901 | - | 'From: Test User <test@example.com>', | |
| 2902 | - | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 2903 | - | 'Subject: [PATCH] Add label-test.txt', | |
| 2904 | - | '', | |
| 2905 | - | '---', | |
| 2906 | - | 'diff --git a/label-test.txt b/label-test.txt', | |
| 2907 | - | 'new file mode 100644', | |
| 2908 | - | 'index 0000000..9daeafb', | |
| 2909 | - | '--- /dev/null', | |
| 2910 | - | '+++ b/label-test.txt', | |
| 2911 | - | '@@ -0,0 +1 @@', | |
| 2912 | - | '+x', | |
| 2913 | - | '', | |
| 2914 | - | ].join('\n'); | |
| 2915 | - | ||
| 2916 | - | beforeAll(async () => { | |
| 2917 | - | adminCtx = await loggedInContext(); | |
| 2918 | - | ||
| 2919 | - | // Create a dedicated repo for label tests | |
| 2920 | - | const page = await adminCtx.newPage(); | |
| 2921 | - | try { | |
| 2922 | - | await page.goto(`${BASE}/new`); | |
| 2923 | - | await page.fill('[name=name]', 'label-repo'); | |
| 2924 | - | await page.click('form[action="/new"] button[type=submit]'); | |
| 2925 | - | await page.waitForURL(`${BASE}/label-repo`); | |
| 2926 | - | } finally { await page.close(); } | |
| 2927 | - | ||
| 2928 | - | // Create an issue | |
| 2929 | - | const issuePage = await adminCtx.newPage(); | |
| 2930 | - | try { | |
| 2931 | - | await issuePage.goto(`${BASE}/label-repo/issues/new`); | |
| 2932 | - | await issuePage.fill('[name=title]', 'Labelled issue'); | |
| 2933 | - | await issuePage.click('form[action$="/issues"] button[type=submit]'); | |
| 2934 | - | await issuePage.waitForURL(/\/label-repo\/issues\/\d+/); | |
| 2935 | - | issueUrl = issuePage.url(); | |
| 2936 | - | } finally { await issuePage.close(); } | |
| 2937 | - | ||
| 2938 | - | // Create a patch | |
| 2939 | - | writeTempFile('/tmp/label-test.patch', VALID_PATCH); | |
| 2940 | - | const patchPage = await adminCtx.newPage(); | |
| 2941 | - | try { | |
| 2942 | - | await patchPage.goto(`${BASE}/label-repo/patches/new`); | |
| 2943 | - | await patchPage.fill('[name=title]', 'Labelled patch'); | |
| 2944 | - | await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch'); | |
| 2945 | - | await patchPage.click('form[action$="/patches"] button[type=submit]'); | |
| 2946 | - | await patchPage.waitForURL(/\/label-repo\/patches\/\d+/); | |
| 2947 | - | patchUrl = patchPage.url(); | |
| 2948 | - | } finally { await patchPage.close(); } | |
| 2949 | - | }); | |
| 2950 | - | ||
| 2951 | - | afterAll(async () => { await adminCtx.close(); }); | |
| 2952 | - | ||
| 2953 | - | test('create label in repo settings', async () => { | |
| 2954 | - | const page = await adminCtx.newPage(); | |
| 2955 | - | try { | |
| 2956 | - | await page.goto(`${BASE}/label-repo/settings`); | |
| 2957 | - | await page.fill('input[name=name]', 'bug'); | |
| 2958 | - | await page.locator('input[type=color][name=color]').evaluate( | |
| 2959 | - | (el: any) => { el.value = '#ff0000'; }, | |
| 2960 | - | ); | |
| 2961 | - | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 2962 | - | await page.waitForURL(/\/label-repo\/settings/); | |
| 2963 | - | expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug'); | |
| 2964 | - | } finally { await page.close(); } | |
| 2965 | - | }); | |
| 2966 | - | ||
| 2967 | - | test('create a second label', async () => { | |
| 2968 | - | const page = await adminCtx.newPage(); | |
| 2969 | - | try { | |
| 2970 | - | await page.goto(`${BASE}/label-repo/settings`); | |
| 2971 | - | await page.fill('input[name=name]', 'enhancement'); | |
| 2972 | - | await page.locator('input[type=color][name=color]').evaluate( | |
| 2973 | - | (el: any) => { el.value = '#00aa00'; }, | |
| 2974 | - | ); | |
| 2975 | - | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 2976 | - | await page.waitForURL(/\/label-repo\/settings/); | |
| 2977 | - | const badges = await page.locator('.label-settings-name').allTextContents(); | |
| 2978 | - | expect(badges).toContain('bug'); | |
| 2979 | - | expect(badges).toContain('enhancement'); | |
| 2980 | - | } finally { await page.close(); } | |
| 2981 | - | }); | |
| 2982 | - | ||
| 2983 | - | test('duplicate label name is rejected', async () => { | |
| 2984 | - | const page = await adminCtx.newPage(); | |
| 2985 | - | try { | |
| 2986 | - | await page.goto(`${BASE}/label-repo/settings`); | |
| 2987 | - | await page.fill('input[name=name]', 'bug'); | |
| 2988 | - | await page.locator('input[type=color][name=color]').evaluate( | |
| 2989 | - | (el: any) => { el.value = '#0000ff'; }, | |
| 2990 | - | ); | |
| 2991 | - | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 2992 | - | await page.waitForURL(/\/label-repo\/settings/); | |
| 2993 | - | expect(await page.locator('.form-error').isVisible()).toBe(true); | |
| 2994 | - | } finally { await page.close(); } | |
| 2995 | - | }); | |
| 2996 | - | ||
| 2997 | - | test('non-admin cannot create labels', async () => { | |
| 2998 | - | const ctx = await browser.newContext(); | |
| 2999 | - | try { | |
| 3000 | - | const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, { | |
| 3001 | - | form: { name: 'nope', color: '#123456' }, | |
| 3002 | - | maxRedirects: 0, | |
| 3003 | - | }); | |
| 3004 | - | // Unauthenticated → redirected to /login | |
| 3005 | - | expect(r.status()).toBe(302); | |
| 3006 | - | expect(r.headers()['location']).toContain('/login'); | |
| 3007 | - | } finally { await ctx.close(); } | |
| 3008 | - | }); | |
| 3009 | - | ||
| 3010 | - | test('assign label to issue', async () => { | |
| 3011 | - | const page = await adminCtx.newPage(); | |
| 3012 | - | try { | |
| 3013 | - | await page.goto(issueUrl); | |
| 3014 | - | await page.selectOption('select[name=label_id]', { label: 'bug' }); | |
| 3015 | - | await page.click('form[action$="/labels/add"] button[type=submit]'); | |
| 3016 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 3017 | - | expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); | |
| 3018 | - | } finally { await page.close(); } | |
| 3019 | - | }); | |
| 3020 | - | ||
| 3021 | - | test('label appears on issue list', async () => { | |
| 3022 | - | const page = await adminCtx.newPage(); | |
| 3023 | - | try { | |
| 3024 | - | await page.goto(`${BASE}/label-repo/issues`); | |
| 3025 | - | const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' }); | |
| 3026 | - | expect(await item.locator('.label-badge').allTextContents()).toContain('bug'); | |
| 3027 | - | } finally { await page.close(); } | |
| 3028 | - | }); | |
| 3029 | - | ||
| 3030 | - | test('filter issues by label shows only matching issues', async () => { | |
| 3031 | - | // Create a second issue without the label | |
| 3032 | - | const createPage = await adminCtx.newPage(); | |
| 3033 | - | try { | |
| 3034 | - | await createPage.goto(`${BASE}/label-repo/issues/new`); | |
| 3035 | - | await createPage.fill('[name=title]', 'Unlabelled issue'); | |
| 3036 | - | await createPage.click('form[action$="/issues"] button[type=submit]'); | |
| 3037 | - | await createPage.waitForURL(/\/label-repo\/issues\/\d+/); | |
| 3038 | - | } finally { await createPage.close(); } | |
| 3039 | - | ||
| 3040 | - | // Open filter popup and apply label filter | |
| 3041 | - | const page = await adminCtx.newPage(); | |
| 3042 | - | try { | |
| 3043 | - | await page.goto(`${BASE}/label-repo/issues`); | |
| 3044 | - | // Get the label id from the checkbox | |
| 3045 | - | const checkbox = page.locator('.label-filter-item input[name=labels]').first(); | |
| 3046 | - | const labelId = await checkbox.getAttribute('value'); | |
| 3047 | - | expect(labelId).toBeTruthy(); | |
| 3048 | - | ||
| 3049 | - | // Navigate with the filter applied via URL | |
| 3050 | - | await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`); | |
| 3051 | - | const titles = await page.locator('.issue-title').allTextContents(); | |
| 3052 | - | expect(titles.some(t => t.includes('Labelled issue'))).toBe(true); | |
| 3053 | - | expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false); | |
| 3054 | - | } finally { await page.close(); } | |
| 3055 | - | }); | |
| 3056 | - | ||
| 3057 | - | test('filter popup is visible without JS', async () => { | |
| 3058 | - | // details/summary is a native HTML element — verify it renders | |
| 3059 | - | const page = await adminCtx.newPage(); | |
| 3060 | - | try { | |
| 3061 | - | await page.goto(`${BASE}/label-repo/issues`); | |
| 3062 | - | expect(await page.locator('details.label-filter').isVisible()).toBe(true); | |
| 3063 | - | expect(await page.locator('details.label-filter summary').isVisible()).toBe(true); | |
| 3064 | - | } finally { await page.close(); } | |
| 3065 | - | }); | |
| 3066 | - | ||
| 3067 | - | test('remove label from issue', async () => { | |
| 3068 | - | const page = await adminCtx.newPage(); | |
| 3069 | - | try { | |
| 3070 | - | await page.goto(issueUrl); | |
| 3071 | - | await page.click('form[action$="/labels/remove"] button[type=submit]'); | |
| 3072 | - | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 3073 | - | // Label badge should no longer appear in the labels row | |
| 3074 | - | const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); | |
| 3075 | - | expect(labelBadges).not.toContain('bug'); | |
| 3076 | - | } finally { await page.close(); } | |
| 3077 | - | }); | |
| 3078 | - | ||
| 3079 | - | test('assign label to patch', async () => { | |
| 3080 | - | const page = await adminCtx.newPage(); | |
| 3081 | - | try { | |
| 3082 | - | await page.goto(patchUrl); | |
| 3083 | - | await page.selectOption('select[name=label_id]', { label: 'enhancement' }); | |
| 3084 | - | await page.click('form[action$="/labels/add"] button[type=submit]'); | |
| 3085 | - | await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); | |
| 3086 | - | expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement'); | |
| 3087 | - | } finally { await page.close(); } | |
| 3088 | - | }); | |
| 3089 | - | ||
| 3090 | - | test('label appears on patch list', async () => { | |
| 3091 | - | const page = await adminCtx.newPage(); | |
| 3092 | - | try { | |
| 3093 | - | await page.goto(`${BASE}/label-repo/patches`); | |
| 3094 | - | const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' }); | |
| 3095 | - | expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement'); | |
| 3096 | - | } finally { await page.close(); } | |
| 3097 | - | }); | |
| 3098 | - | ||
| 3099 | - | test('filter patches by label', async () => { | |
| 3100 | - | const page = await adminCtx.newPage(); | |
| 3101 | - | try { | |
| 3102 | - | await page.goto(`${BASE}/label-repo/patches`); | |
| 3103 | - | // Find the checkbox for the 'enhancement' label specifically | |
| 3104 | - | const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' }) | |
| 3105 | - | .locator('input[name=labels]'); | |
| 3106 | - | const labelId = await checkbox.getAttribute('value'); | |
| 3107 | - | expect(labelId).toBeTruthy(); | |
| 3108 | - | ||
| 3109 | - | await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`); | |
| 3110 | - | const titles = await page.locator('.issue-title').allTextContents(); | |
| 3111 | - | expect(titles.some(t => t.includes('Labelled patch'))).toBe(true); | |
| 3112 | - | } finally { await page.close(); } | |
| 3113 | - | }); | |
| 3114 | - | ||
| 3115 | - | test('remove label from patch', async () => { | |
| 3116 | - | const page = await adminCtx.newPage(); | |
| 3117 | - | try { | |
| 3118 | - | await page.goto(patchUrl); | |
| 3119 | - | await page.click('form[action$="/labels/remove"] button[type=submit]'); | |
| 3120 | - | await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); | |
| 3121 | - | const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); | |
| 3122 | - | expect(labelBadges).not.toContain('enhancement'); | |
| 3123 | - | } finally { await page.close(); } | |
| 3124 | - | }); | |
| 3125 | - | ||
| 3126 | - | test('delete label removes it from settings list', async () => { | |
| 3127 | - | const page = await adminCtx.newPage(); | |
| 3128 | - | try { | |
| 3129 | - | await page.goto(`${BASE}/label-repo/settings`); | |
| 3130 | - | const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' }); | |
| 3131 | - | await bugItem.locator('form[action$="/labels/delete"] button').click(); | |
| 3132 | - | await page.waitForURL(/\/label-repo\/settings/); | |
| 3133 | - | const badges = await page.locator('.label-settings-name').allTextContents(); | |
| 3134 | - | expect(badges).not.toContain('bug'); | |
| 3135 | - | } finally { await page.close(); } | |
| 3136 | - | }); | |
| 3137 | - | ||
| 3138 | - | test('deleted label no longer appears in filter popup', async () => { | |
| 3139 | - | const page = await adminCtx.newPage(); | |
| 3140 | - | try { | |
| 3141 | - | await page.goto(`${BASE}/label-repo/issues`); | |
| 3142 | - | const filterLabels = await page.locator('.label-filter-item').allTextContents(); | |
| 3143 | - | expect(filterLabels.every(t => !t.includes('bug'))).toBe(true); | |
| 3144 | - | } finally { await page.close(); } | |
| 3145 | - | }); | |
| 3146 | - | }); | |
| 3147 | - | ||
| 3148 | - | // ─── User label management ──────────────────────────────────────────────────── | |
| 3149 | - | ||
| 3150 | - | describe('user label management', () => { | |
| 3151 | - | let adminCtx: BrowserContext; | |
| 3152 | - | let aliceCtx: BrowserContext; | |
| 3153 | - | // URL of an issue owned by alice | |
| 3154 | - | let aliceIssueUrl: string; | |
| 3155 | - | // URL of an issue owned by admin | |
| 3156 | - | let adminIssueUrl: string; | |
| 3157 | - | // label IDs, fetched from the filter inputs | |
| 3158 | - | let bugLabelId: string; | |
| 3159 | - | ||
| 3160 | - | const VALID_PATCH = [ | |
| 3161 | - | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 3162 | - | 'From: Test User <test@example.com>', | |
| 3163 | - | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 3164 | - | 'Subject: [PATCH] Add ulm-test.txt', | |
| 3165 | - | '', | |
| 3166 | - | '---', | |
| 3167 | - | 'diff --git a/ulm-test.txt b/ulm-test.txt', | |
| 3168 | - | 'new file mode 100644', | |
| 3169 | - | 'index 0000000..9daeafb', | |
| 3170 | - | '--- /dev/null', | |
| 3171 | - | '+++ b/ulm-test.txt', | |
| 3172 | - | '@@ -0,0 +1 @@', | |
| 3173 | - | '+x', | |
| 3174 | - | '', | |
| 3175 | - | ].join('\n'); | |
| 3176 | - | ||
| 3177 | - | beforeAll(async () => { | |
| 3178 | - | adminCtx = await loggedInContext(); | |
| 3179 | - | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 3180 | - | ||
| 3181 | - | // Create dedicated repo | |
| 3182 | - | const repoPage = await adminCtx.newPage(); | |
| 3183 | - | try { | |
| 3184 | - | await repoPage.goto(`${BASE}/new`); | |
| 3185 | - | await repoPage.fill('[name=name]', 'ulm-repo'); | |
| 3186 | - | await repoPage.click('form[action="/new"] button[type=submit]'); | |
| 3187 | - | await repoPage.waitForURL(`${BASE}/ulm-repo`); | |
| 3188 | - | } finally { await repoPage.close(); } | |
| 3189 | - | ||
| 3190 | - | // Create labels 'bug' and 'feature' | |
| 3191 | - | for (const name of ['bug', 'feature']) { | |
| 3192 | - | const p = await adminCtx.newPage(); | |
| 3193 | - | try { | |
| 3194 | - | await p.goto(`${BASE}/ulm-repo/settings`); | |
| 3195 | - | await p.fill('input[name=name]', name); | |
| 3196 | - | await p.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 3197 | - | await p.waitForURL(/\/ulm-repo\/settings/); | |
| 3198 | - | } finally { await p.close(); } | |
| 3199 | - | } | |
| 3200 | - | ||
| 3201 | - | // Create an issue owned by admin | |
| 3202 | - | const adminIssuePage = await adminCtx.newPage(); | |
| 3203 | - | try { | |
| 3204 | - | await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`); | |
| 3205 | - | await adminIssuePage.fill('[name=title]', "Admin's issue"); | |
| 3206 | - | await adminIssuePage.click('form[action$="/issues"] button[type=submit]'); | |
| 3207 | - | await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/); | |
| 3208 | - | adminIssueUrl = adminIssuePage.url(); | |
| 3209 | - | } finally { await adminIssuePage.close(); } | |
| 3210 | - | ||
| 3211 | - | // Create an issue owned by alice | |
| 3212 | - | const aliceIssuePage = await aliceCtx.newPage(); | |
| 3213 | - | try { | |
| 3214 | - | await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`); | |
| 3215 | - | await aliceIssuePage.fill('[name=title]', "Alice's issue"); | |
| 3216 | - | await aliceIssuePage.click('form[action$="/issues"] button[type=submit]'); | |
| 3217 | - | await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/); | |
| 3218 | - | aliceIssueUrl = aliceIssuePage.url(); | |
| 3219 | - | } finally { await aliceIssuePage.close(); } | |
| 3220 | - | ||
| 3221 | - | // Grab the bug label id from the filter popup | |
| 3222 | - | const filterPage = await adminCtx.newPage(); | |
| 3223 | - | try { | |
| 3224 | - | await filterPage.goto(`${BASE}/ulm-repo/issues`); | |
| 3225 | - | const checkbox = filterPage.locator('.label-filter-item') | |
| 3226 | - | .filter({ hasText: 'bug' }) | |
| 3227 | - | .locator('input[name=labels]'); | |
| 3228 | - | bugLabelId = (await checkbox.getAttribute('value')) ?? ''; | |
| 3229 | - | } finally { await filterPage.close(); } | |
| 3230 | - | }); | |
| 3231 | - | ||
| 3232 | - | afterAll(async () => { | |
| 3233 | - | await adminCtx.close(); | |
| 3234 | - | await aliceCtx.close(); | |
| 3235 | - | }); | |
| 3236 | - | ||
| 3237 | - | // ── Settings ── | |
| 3238 | - | ||
| 3239 | - | test('allow_user_labels checkbox is present in repo settings', async () => { | |
| 3240 | - | const page = await adminCtx.newPage(); | |
| 3241 | - | try { | |
| 3242 | - | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 3243 | - | expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1); | |
| 3244 | - | } finally { await page.close(); } | |
| 3245 | - | }); | |
| 3246 | - | ||
| 3247 | - | test('allow_user_labels is off by default', async () => { | |
| 3248 | - | const page = await adminCtx.newPage(); | |
| 3249 | - | try { | |
| 3250 | - | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 3251 | - | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false); | |
| 3252 | - | } finally { await page.close(); } | |
| 3253 | - | }); | |
| 3254 | - | ||
| 3255 | - | // ── Label checkboxes hidden when setting is off ── | |
| 3256 | - | ||
| 3257 | - | test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => { | |
| 3258 | - | const page = await aliceCtx.newPage(); | |
| 3259 | - | try { | |
| 3260 | - | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 3261 | - | expect(await page.locator('.label-checkbox-list').count()).toBe(0); | |
| 3262 | - | } finally { await page.close(); } | |
| 3263 | - | }); | |
| 3264 | - | ||
| 3265 | - | test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => { | |
| 3266 | - | const page = await aliceCtx.newPage(); | |
| 3267 | - | try { | |
| 3268 | - | await page.goto(`${BASE}/ulm-repo/patches/new`); | |
| 3269 | - | expect(await page.locator('.label-checkbox-list').count()).toBe(0); | |
| 3270 | - | } finally { await page.close(); } | |
| 3271 | - | }); | |
| 3272 | - | ||
| 3273 | - | test('label checkboxes shown to admin on new issue form regardless of setting', async () => { | |
| 3274 | - | const page = await adminCtx.newPage(); | |
| 3275 | - | try { | |
| 3276 | - | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 3277 | - | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); | |
| 3278 | - | } finally { await page.close(); } | |
| 3279 | - | }); | |
| 3280 | - | ||
| 3281 | - | // ── Enable the setting ── | |
| 3282 | - | ||
| 3283 | - | test('admin can enable allow_user_labels', async () => { | |
| 3284 | - | const page = await adminCtx.newPage(); | |
| 3285 | - | try { | |
| 3286 | - | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 3287 | - | await page.check('input[name=allow_user_labels]'); | |
| 3288 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 3289 | - | await page.waitForURL(/\/ulm-repo\/settings/); | |
| 3290 | - | // Verify it persisted | |
| 3291 | - | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 3292 | - | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true); | |
| 3293 | - | } finally { await page.close(); } | |
| 3294 | - | }); | |
| 3295 | - | ||
| 3296 | - | // ── Label checkboxes visible when setting is on ── | |
| 3297 | - | ||
| 3298 | - | test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => { | |
| 3299 | - | const page = await aliceCtx.newPage(); | |
| 3300 | - | try { | |
| 3301 | - | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 3302 | - | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); | |
| 3303 | - | const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents(); | |
| 3304 | - | expect(labels).toContain('bug'); | |
| 3305 | - | expect(labels).toContain('feature'); | |
| 3306 | - | } finally { await page.close(); } | |
| 3307 | - | }); | |
| 3308 | - | ||
| 3309 | - | test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => { | |
| 3310 | - | const page = await aliceCtx.newPage(); | |
| 3311 | - | try { | |
| 3312 | - | await page.goto(`${BASE}/ulm-repo/patches/new`); | |
| 3313 | - | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); | |
| 3314 | - | } finally { await page.close(); } | |
| 3315 | - | }); | |
| 3316 | - | ||
| 3317 | - | // ── Creating with labels selected ── | |
| 3318 | - | ||
| 3319 | - | test('non-admin can create issue with label selected', async () => { | |
| 3320 | - | const page = await aliceCtx.newPage(); | |
| 3321 | - | try { | |
| 3322 | - | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 3323 | - | await page.fill('[name=title]', 'Issue with label'); | |
| 3324 | - | // Check the 'bug' label checkbox | |
| 3325 | - | await page.locator('.label-checkbox-item').filter({ hasText: 'bug' }) | |
| 3326 | - | .locator('input[type=checkbox]').check(); | |
| 3327 | - | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 3328 | - | await page.waitForURL(/\/ulm-repo\/issues\/\d+/); | |
| 3329 | - | const badges = await page.locator('.label-badge').allTextContents(); | |
| 3330 | - | expect(badges).toContain('bug'); | |
| 3331 | - | } finally { await page.close(); } | |
| 3332 | - | }); | |
| 3333 | - | ||
| 3334 | - | test('non-admin can create patch with label selected', async () => { | |
| 3335 | - | writeTempFile('/tmp/ulm-test.patch', VALID_PATCH); | |
| 3336 | - | const page = await aliceCtx.newPage(); | |
| 3337 | - | try { | |
| 3338 | - | await page.goto(`${BASE}/ulm-repo/patches/new`); | |
| 3339 | - | await page.fill('[name=title]', 'Patch with label'); | |
| 3340 | - | await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch'); | |
| 3341 | - | await page.locator('.label-checkbox-item').filter({ hasText: 'feature' }) | |
| 3342 | - | .locator('input[type=checkbox]').check(); | |
| 3343 | - | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 3344 | - | await page.waitForURL(/\/ulm-repo\/patches\/\d+/); | |
| 3345 | - | const badges = await page.locator('.label-badge').allTextContents(); | |
| 3346 | - | expect(badges).toContain('feature'); | |
| 3347 | - | } finally { await page.close(); } | |
| 3348 | - | }); | |
| 3349 | - | ||
| 3350 | - | test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => { | |
| 3351 | - | // Temporarily disable the setting, post with label_ids, re-enable | |
| 3352 | - | await adminCtx.request.post(`${BASE}/ulm-repo/settings`, { | |
| 3353 | - | form: { description: '', default_branch: 'main' }, // no allow_user_labels | |
| 3354 | - | maxRedirects: 0, | |
| 3355 | - | }).catch(() => {}); | |
| 3356 | - | ||
| 3357 | - | const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, { | |
| 3358 | - | form: { title: 'Issue sneaking labels', label_ids: bugLabelId }, | |
| 3359 | - | maxRedirects: 0, | |
| 3360 | - | }).catch(() => null); | |
| 3361 | - | // Should redirect to the new issue | |
| 3362 | - | const location = resp?.headers()['location'] ?? ''; | |
| 3363 | - | const issueNum = location.split('/issues/')[1]; | |
| 3364 | - | ||
| 3365 | - | if (issueNum) { | |
| 3366 | - | const page = await aliceCtx.newPage(); | |
| 3367 | - | try { | |
| 3368 | - | await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`); | |
| 3369 | - | const badges = await page.locator('.label-badge').allTextContents(); | |
| 3370 | - | expect(badges).not.toContain('bug'); | |
| 3371 | - | } finally { await page.close(); } | |
| 3372 | - | } | |
| 3373 | - | ||
| 3374 | - | // Re-enable for subsequent tests | |
| 3375 | - | await adminCtx.request.post(`${BASE}/ulm-repo/settings`, { | |
| 3376 | - | form: { description: '', default_branch: 'main', allow_user_labels: '1' }, | |
| 3377 | - | maxRedirects: 0, | |
| 3378 | - | }).catch(() => {}); | |
| 3379 | - | }); | |
| 3380 | - | ||
| 3381 | - | // ── Add/remove labels on existing items ── | |
| 3382 | - | ||
| 3383 | - | test('non-admin can add label to their own issue', async () => { | |
| 3384 | - | const issueNum = aliceIssueUrl.split('/issues/')[1]; | |
| 3385 | - | const page = await aliceCtx.newPage(); | |
| 3386 | - | try { | |
| 3387 | - | await page.goto(aliceIssueUrl); | |
| 3388 | - | await page.selectOption('select[name=label_id]', { label: 'bug' }); | |
| 3389 | - | await page.click('form[action$="/labels/add"] button[type=submit]'); | |
| 3390 | - | await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`)); | |
| 3391 | - | expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); | |
| 3392 | - | } finally { await page.close(); } | |
| 3393 | - | }); | |
| 3394 | - | ||
| 3395 | - | test('non-admin can remove label from their own issue', async () => { | |
| 3396 | - | const issueNum = aliceIssueUrl.split('/issues/')[1]; | |
| 3397 | - | const page = await aliceCtx.newPage(); | |
| 3398 | - | try { | |
| 3399 | - | await page.goto(aliceIssueUrl); | |
| 3400 | - | await page.click('form[action$="/labels/remove"] button[type=submit]'); | |
| 3401 | - | await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`)); | |
| 3402 | - | const badges = await page.locator('.issue-labels-row .label-badge').allTextContents(); | |
| 3403 | - | expect(badges).not.toContain('bug'); | |
| 3404 | - | } finally { await page.close(); } | |
| 3405 | - | }); | |
| 3406 | - | ||
| 3407 | - | test('non-admin cannot add label to another user\'s issue', async () => { | |
| 3408 | - | const issueNum = adminIssueUrl.split('/issues/')[1]; | |
| 3409 | - | const resp = await aliceCtx.request.post( | |
| 3410 | - | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, | |
| 3411 | - | { form: { label_id: bugLabelId }, maxRedirects: 0 }, | |
| 3412 | - | ); | |
| 3413 | - | expect(resp.status()).toBe(403); | |
| 3414 | - | }); | |
| 3415 | - | ||
| 3416 | - | test('unauthenticated user gets 401 adding a label', async () => { | |
| 3417 | - | const issueNum = aliceIssueUrl.split('/issues/')[1]; | |
| 3418 | - | const ctx = await browser.newContext(); | |
| 3419 | - | try { | |
| 3420 | - | const resp = await ctx.request.post( | |
| 3421 | - | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, | |
| 3422 | - | { form: { label_id: bugLabelId }, maxRedirects: 0 }, | |
| 3423 | - | ); | |
| 3424 | - | expect(resp.status()).toBe(401); | |
| 3425 | - | } finally { await ctx.close(); } | |
| 3426 | - | }); | |
| 3427 | - | ||
| 3428 | - | // ── Disable setting and verify enforcement ── | |
| 3429 | - | ||
| 3430 | - | test('admin can disable allow_user_labels', async () => { | |
| 3431 | - | const page = await adminCtx.newPage(); | |
| 3432 | - | try { | |
| 3433 | - | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 3434 | - | await page.uncheck('input[name=allow_user_labels]'); | |
| 3435 | - | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 3436 | - | await page.waitForURL(/\/ulm-repo\/settings/); | |
| 3437 | - | await page.goto(`${BASE}/ulm-repo/settings`); | |
| 3438 | - | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false); | |
| 3439 | - | } finally { await page.close(); } | |
| 3440 | - | }); | |
| 3441 | - | ||
| 3442 | - | test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => { | |
| 3443 | - | const issueNum = aliceIssueUrl.split('/issues/')[1]; | |
| 3444 | - | const resp = await aliceCtx.request.post( | |
| 3445 | - | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, | |
| 3446 | - | { form: { label_id: bugLabelId }, maxRedirects: 0 }, | |
| 3447 | - | ); | |
| 3448 | - | expect(resp.status()).toBe(403); | |
| 3449 | - | }); | |
| 3450 | - | ||
| 3451 | - | test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => { | |
| 3452 | - | const page = await aliceCtx.newPage(); | |
| 3453 | - | try { | |
| 3454 | - | await page.goto(`${BASE}/ulm-repo/issues/new`); | |
| 3455 | - | expect(await page.locator('.label-checkbox-list').count()).toBe(0); | |
| 3456 | - | } finally { await page.close(); } | |
| 3457 | - | }); | |
| 3458 | - | }); | |
Mtests/helpers.ts
| @@ -1,12 +1,21 @@ | |||
|---|---|---|---|
| 1 | - | import { $ } from 'bun'; | |
| 2 | 1 | import { createServer } from 'net'; | |
| 3 | 2 | import { rmSync, mkdirSync, writeFileSync } from 'fs'; | |
| 3 | + | import { spawnSync as nodeSpawnSync } from 'child_process'; | |
| 4 | 4 | import path from 'path'; | |
| 5 | 5 | import type { Page } from 'playwright'; | |
| 6 | + | import config from '../src/config.ts'; | |
| 7 | + | import { createApp } from '../src/app.ts'; | |
| 8 | + | import { resetDb } from '../src/db/index.ts'; | |
| 6 | 9 | ||
| 7 | 10 | export const DATA_DIR = './data-test'; | |
| 8 | 11 | export const ADMIN_PASS = 'testpass123'; | |
| 9 | 12 | ||
| 13 | + | // Override config for tests — this runs at import time, before any app code | |
| 14 | + | // reads these values, so no preload script is needed. | |
| 15 | + | config.DATA_DIR = path.resolve(DATA_DIR); | |
| 16 | + | config.RATE_LIMIT_DISABLED = true; | |
| 17 | + | config.SSH_DISABLED = true; | |
| 18 | + | ||
| 10 | 19 | /** Bind to port 0 and return the OS-assigned free port number. */ | |
| 11 | 20 | function getFreePort(): Promise<number> { | |
| 12 | 21 | return new Promise((resolve, reject) => { | |
| @@ -23,46 +32,30 @@ function getFreePort(): Promise<number> { | |||
|---|---|---|---|
| 23 | 32 | export let PORT = 0; | |
| 24 | 33 | export let BASE = ''; | |
| 25 | 34 | ||
| 35 | + | /** Server handle — an Elysia app instance running in-process. */ | |
| 36 | + | export type ServerHandle = Awaited<ReturnType<typeof createApp>>; | |
| 37 | + | ||
| 26 | 38 | export async function setupTestEnv() { | |
| 27 | 39 | PORT = await getFreePort(); | |
| 28 | 40 | BASE = `http://localhost:${PORT}`; | |
| 29 | - | ||
| 30 | 41 | rmSync(DATA_DIR, { recursive: true, force: true }); | |
| 31 | 42 | mkdirSync(`${DATA_DIR}/repos`, { recursive: true }); | |
| 32 | 43 | ||
| 33 | - | const init = Bun.spawn(['bun', 'run', 'src/db/init.ts'], { | |
| 44 | + | nodeSpawnSync('bun', ['run', 'src/db/init.ts'], { | |
| 34 | 45 | env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS }, | |
| 35 | - | stdout: 'pipe', | |
| 36 | - | stderr: 'pipe', | |
| 46 | + | stdio: 'ignore', | |
| 37 | 47 | }); | |
| 38 | - | await init.exited; | |
| 48 | + | ||
| 49 | + | // Reopen DB connection to the freshly-created database file. | |
| 50 | + | resetDb(); | |
| 39 | 51 | } | |
| 40 | 52 | ||
| 41 | - | export function spawnServer() { | |
| 42 | - | return Bun.spawn(['bun', 'run', 'src/index.tsx'], { | |
| 43 | - | env: { | |
| 44 | - | ...process.env, | |
| 45 | - | PORT: String(PORT), | |
| 46 | - | DATA_DIR, | |
| 47 | - | RATE_LIMIT_DISABLED: 'true', | |
| 48 | - | SSH_DISABLED: 'true', | |
| 49 | - | }, | |
| 50 | - | stdout: 'ignore', | |
| 51 | - | stderr: 'ignore', | |
| 52 | - | }); | |
| 53 | + | export async function spawnServer(): Promise<ServerHandle> { | |
| 54 | + | return createApp(PORT); | |
| 53 | 55 | } | |
| 54 | 56 | ||
| 55 | - | export async function waitForServer(maxMs = 10_000) { | |
| 56 | - | const deadline = Date.now() + maxMs; | |
| 57 | - | while (Date.now() < deadline) { | |
| 58 | - | try { | |
| 59 | - | await fetch(BASE); | |
| 60 | - | return; | |
| 61 | - | } catch { | |
| 62 | - | await Bun.sleep(150); | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | throw new Error('Server did not start in time'); | |
| 57 | + | export async function killServer(app: ServerHandle): Promise<void> { | |
| 58 | + | await app.stop(true); | |
| 66 | 59 | } | |
| 67 | 60 | ||
| 68 | 61 | export async function login( | |
| @@ -82,21 +75,29 @@ export async function logout(page: Page) { | |||
|---|---|---|---|
| 82 | 75 | await page.waitForURL(BASE + '/'); | |
| 83 | 76 | } | |
| 84 | 77 | ||
| 78 | + | const git = (args: string[], cwd?: string) => | |
| 79 | + | nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: 'ignore' }); | |
| 80 | + | ||
| 81 | + | export function gitOutput(args: string[], cwd?: string): string { | |
| 82 | + | const r = nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: ['ignore', 'pipe', 'ignore'] }); | |
| 83 | + | return r.stdout?.toString().trim() ?? ''; | |
| 84 | + | } | |
| 85 | + | ||
| 85 | 86 | /** Push an initial commit into a bare repo that already exists on disk. */ | |
| 86 | - | export async function seedRepo(name: string) { | |
| 87 | + | export function seedRepo(name: string) { | |
| 87 | 88 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; | |
| 88 | 89 | const tmp = `/tmp/hf-seed-${Date.now()}`; | |
| 89 | 90 | try { | |
| 90 | - | await $`git clone ${repoPath} ${tmp}`.quiet(); | |
| 91 | - | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); | |
| 92 | - | await $`git -C ${tmp} config user.name "Test"`.quiet(); | |
| 91 | + | git(['clone', repoPath, tmp]); | |
| 92 | + | git(['config', 'user.email', 'test@test.com'], tmp); | |
| 93 | + | git(['config', 'user.name', 'Test'], tmp); | |
| 93 | 94 | writeFileSync(`${tmp}/README.md`, `# ${name}\n`); | |
| 94 | 95 | writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`); | |
| 95 | - | await $`git -C ${tmp} add -A`.quiet(); | |
| 96 | - | await $`git -C ${tmp} commit -m "Initial commit"`.quiet(); | |
| 97 | - | await $`git -C ${tmp} push origin HEAD:main`.quiet(); | |
| 96 | + | git(['add', '-A'], tmp); | |
| 97 | + | git(['commit', '-m', 'Initial commit'], tmp); | |
| 98 | + | git(['push', 'origin', 'HEAD:main'], tmp); | |
| 98 | 99 | } finally { | |
| 99 | - | await $`rm -rf ${tmp}`.quiet().nothrow(); | |
| 100 | + | rmSync(tmp, { recursive: true, force: true }); | |
| 100 | 101 | } | |
| 101 | 102 | } | |
| 102 | 103 | ||
| @@ -105,42 +106,45 @@ export function writeTempFile(path: string, content: string) { | |||
|---|---|---|---|
| 105 | 106 | } | |
| 106 | 107 | ||
| 107 | 108 | /** Commit a subdirectory with the given files into an existing repo on main. */ | |
| 108 | - | export async function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) { | |
| 109 | + | export function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) { | |
| 109 | 110 | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; | |
| 110 | 111 | const tmp = `/tmp/hf-subdir-${Date.now()}`; | |
| 111 | 112 | try { | |
| 112 | - | await $`git clone ${repoDir} ${tmp}`.quiet(); | |
| 113 | - | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); | |
| 114 | - | await $`git -C ${tmp} config user.name "Test"`.quiet(); | |
| 113 | + | git(['clone', repoDir, tmp]); | |
| 114 | + | git(['config', 'user.email', 'test@test.com'], tmp); | |
| 115 | + | git(['config', 'user.name', 'Test'], tmp); | |
| 115 | 116 | mkdirSync(path.join(tmp, dirPath), { recursive: true }); | |
| 116 | 117 | for (const [fileName, content] of Object.entries(files)) { | |
| 117 | 118 | writeFileSync(path.join(tmp, dirPath, fileName), content); | |
| 118 | 119 | } | |
| 119 | - | await $`git -C ${tmp} add -A`.quiet(); | |
| 120 | - | await $`git -C ${tmp} commit -m ${'Add ' + dirPath}`.quiet(); | |
| 121 | - | await $`git -C ${tmp} push origin HEAD:main`.quiet(); | |
| 120 | + | git(['add', '-A'], tmp); | |
| 121 | + | git(['commit', '-m', `Add ${dirPath}`], tmp); | |
| 122 | + | git(['push', 'origin', 'HEAD:main'], tmp); | |
| 122 | 123 | } finally { | |
| 123 | - | await $`rm -rf ${tmp}`.quiet().nothrow(); | |
| 124 | + | rmSync(tmp, { recursive: true, force: true }); | |
| 124 | 125 | } | |
| 125 | 126 | } | |
| 126 | 127 | ||
| 127 | 128 | /** Return the HEAD commit hash of a repo. */ | |
| 128 | - | export async function getHeadCommit(repoName: string): Promise<string> { | |
| 129 | + | export function getHeadCommit(repoName: string): string { | |
| 129 | 130 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; | |
| 130 | - | return (await $`git -C ${repoPath} rev-parse HEAD`.quiet()).text().trim(); | |
| 131 | + | const result = nodeSpawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { | |
| 132 | + | stdio: ['ignore', 'pipe', 'ignore'], | |
| 133 | + | }); | |
| 134 | + | return result.stdout?.toString().trim() ?? ''; | |
| 131 | 135 | } | |
| 132 | 136 | ||
| 133 | 137 | /** Create a new branch in an existing repo (from current HEAD). */ | |
| 134 | - | export async function seedBranch(name: string, branchName: string) { | |
| 138 | + | export function seedBranch(name: string, branchName: string) { | |
| 135 | 139 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; | |
| 136 | 140 | const tmp = `/tmp/hf-branch-${Date.now()}`; | |
| 137 | 141 | try { | |
| 138 | - | await $`git clone ${repoPath} ${tmp}`.quiet(); | |
| 139 | - | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); | |
| 140 | - | await $`git -C ${tmp} config user.name "Test"`.quiet(); | |
| 141 | - | await $`git -C ${tmp} checkout -b ${branchName}`.quiet(); | |
| 142 | - | await $`git -C ${tmp} push origin ${branchName}`.quiet(); | |
| 142 | + | git(['clone', repoPath, tmp]); | |
| 143 | + | git(['config', 'user.email', 'test@test.com'], tmp); | |
| 144 | + | git(['config', 'user.name', 'Test'], tmp); | |
| 145 | + | git(['checkout', '-b', branchName], tmp); | |
| 146 | + | git(['push', 'origin', branchName], tmp); | |
| 143 | 147 | } finally { | |
| 144 | - | await $`rm -rf ${tmp}`.quiet().nothrow(); | |
| 148 | + | rmSync(tmp, { recursive: true, force: true }); | |
| 145 | 149 | } | |
| 146 | 150 | } | |