refactor css & tests
Mbun.lock
@@ -8,14 +8,12 @@
"@elysiajs/static": "^1.4.7",
"@jsquash/jxl": "^1.3.0",
"@kitajs/html": "^4.2.13",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"argon2": "^0.44.0",
"elysia": "^1.4.27",
"file-type": "^21.3.2",
"isomorphic-dompurify": "^3.3.0",
"jxl-rs-polyfill": "^0.1.1",
"kysely": "^0.28.12",
"kysely": "^0.28.14",
"kysely-bun-sqlite": "^0.4.0",
"linguist-languages": "^9.3.1",
"marked": "^17.0.4",
@@ -25,9 +23,11 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@simplewebauthn/browser": "^13.3.0",
"@types/argon2": "^0.15.4",
"@types/bun": "latest",
"@types/ssh2": "^1.15.5",
"jxl-rs-polyfill": "^0.1.1",
"lightningcss": "^1.32.0",
"playwright": "^1.58.2",
},
@@ -303,7 +303,7 @@
"jxl-rs-polyfill": ["jxl-rs-polyfill@0.1.1", "", {}, "sha512-BFrrvbnInmDYqEcZZZ2nYJq248VMq6BBBcnc7uhAndz1uhIEclW6uMPLQjqTrEVcDg1JeRhq796kuSc+C/JasQ=="],
"kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="],
"kysely": ["kysely@0.28.14", "", {}, "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA=="],
"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=="],
Mpackage.json
@@ -7,7 +7,7 @@
"css:build": "bun scripts/build-css.ts",
"db:init": "bun run src/db/init.ts",
"db:seed": "bun run src/db/seed.ts",
"test": "bun test --timeout 60000",
"test": "bun scripts/test.ts",
"vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm",
"vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js",
"postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill && bun run css:build",
@@ -38,7 +38,7 @@
"elysia": "^1.4.27",
"file-type": "^21.3.2",
"isomorphic-dompurify": "^3.3.0",
"kysely": "^0.28.12",
"kysely": "^0.28.14",
"kysely-bun-sqlite": "^0.4.0",
"linguist-languages": "^9.3.1",
"marked": "^17.0.4",
Mscripts/build-css.ts
@@ -1,14 +1,12 @@
import { watch } from "fs";
import { transform } from "lightningcss";
import { bundle } from "lightningcss";
const INPUT = "src/styles/main.css";
const OUTPUT = "public/assets/main.css";
async function build() {
const source = Bun.file(INPUT);
const { code } = transform({
const { code } = bundle({
filename: INPUT,
code: await source.bytes(),
minify: true,
sourceMap: false,
});
@@ -19,12 +17,12 @@ async function build() {
await build();
if (process.argv.includes("--watch")) {
watch(INPUT, async () => {
watch("src/styles", { recursive: true }, async () => {
try {
await build();
} catch (e) {
console.error("[css]", e);
}
});
console.log(`[css] watching ${INPUT}`);
console.log(`[css] watching src/styles`);
}
Mscripts/dev.ts
@@ -1,9 +1,41 @@
const server = Bun.spawn(["bun", "--watch", "src/index.tsx"], { stdio: ["inherit", "inherit", "inherit"] });
const css = Bun.spawn(["bun", "scripts/build-css.ts", "--watch"], { stdio: ["inherit", "inherit", "inherit"] });
import { watch } from 'fs';
process.on("SIGINT", () => {
const SRC_DIR = 'src';
async function spawnServer() {
return Bun.spawn(['bun', 'run', 'src/index.tsx'], {
stdio: ['inherit', 'inherit', 'inherit'],
});
}
async function restart(current: ReturnType<typeof Bun.spawn>) {
current.kill();
await current.exited;
return spawnServer();
}
let server = await spawnServer();
// Debounce rapid file-save bursts (e.g. formatter touching many files at once)
let debounce: ReturnType<typeof setTimeout> | null = null;
watch(SRC_DIR, { recursive: true }, (_, filename) => {
if (filename?.endsWith('.css')) return; // CSS is handled by build-css.ts --watch
if (debounce) clearTimeout(debounce);
debounce = setTimeout(async () => {
console.clear();
console.log(`[dev] ${filename} changed — restarting…`);
server = await restart(server);
}, 50);
});
const css = Bun.spawn(['bun', 'scripts/build-css.ts', '--watch'], {
stdio: ['inherit', 'inherit', 'inherit'],
});
process.on('SIGINT', async () => {
server.kill();
css.kill();
await Promise.all([server.exited, css.exited]);
process.exit(0);
});
Ascripts/test.ts
@@ -0,0 +1,26 @@
import { readdirSync } from 'fs';
import { execFileSync } from 'child_process';
import path from 'path';
const testsDir = path.resolve('tests');
const files = readdirSync(testsDir)
.filter(f => f.endsWith('.test.ts'))
.sort();
let passed = 0;
let failed = 0;
for (const file of files) {
const filePath = path.join(testsDir, file);
try {
execFileSync('bun', ['test', '--bail=1', '--timeout', '30000', filePath], {
stdio: 'inherit',
});
passed++;
} catch {
failed++;
}
}
console.log(`\n${passed + failed} test files: ${passed} passed${failed ? `, ${failed} failed` : ''}`);
if (failed > 0) process.exit(1);
Asrc/app.ts
@@ -0,0 +1,35 @@
import path from "node:path";
import { staticPlugin } from "@elysiajs/static";
import { Elysia } from "elysia";
import config from "./config.ts";
import { authRoutes } from "./routes/auth.tsx";
import { avatarRoutes } from "./routes/avatars.ts";
import { gitRoutes } from "./routes/git.ts";
import { issueRoutes } from "./routes/issues.tsx";
import { patchRoutes } from "./routes/patches.tsx";
import { releasesRoutes } from "./routes/releases.tsx";
import { repoRoutes } from "./routes/repos.tsx";
import { settingsRoutes } from "./routes/settings.tsx";
import { syncStartup } from "./services/repoSync.ts";
export async function createApp(port: number) {
await syncStartup();
return new Elysia({
serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES },
})
.use(
staticPlugin({
assets: path.resolve("./public"),
prefix: "/",
}),
)
.use(gitRoutes)
.use(settingsRoutes)
.use(authRoutes)
.use(repoRoutes)
.use(issueRoutes)
.use(patchRoutes)
.use(releasesRoutes)
.use(avatarRoutes)
.listen(port);
}
Msrc/config.ts
@@ -1,30 +1,37 @@
import path from "node:path";
//env configs
export const OWNER_DISPLAY_NAME = process.env.OWNER_DISPLAY_NAME ?? "Admin";
export const INLINE_MAX_BYTES =
parseInt(process.env.INLINE_MAX_BYTES ?? "", 10) || 524288;
export const MAX_UPLOAD_BYTES =
parseInt(process.env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024;
export const MAX_USER_UPLOAD_BYTES =
parseInt(process.env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024;
export const TRUSTED_PROXY = !!process.env.TRUSTED_PROXY;
export const RATE_LIMIT_DISABLED = !!process.env.RATE_LIMIT_DISABLED;
export const SSH_DISABLED = !!process.env.SSH_DISABLED;
export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000;
export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222;
export const REGISTRATION_TYPE = (process.env.REGISTRATION_TYPE ?? "enabled") as
| "enabled"
| "disabled"
| "queue";
export const REGISTER_QUESTION = process.env.REGISTER_QUESTION ?? "";
export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
export const HIGHLIGHT_WORKERS =
parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4;
export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME;
export const COMMITTER_EMAIL =
process.env.COMMITTER_EMAIL ??
`${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`;
export const EXTRA_ALLOWED_SIGNERS_PATH =
process.env.EXTRA_ALLOWED_SIGNERS_PATH ?? null;
const env = process.env;
const config = {
OWNER_DISPLAY_NAME: env.OWNER_DISPLAY_NAME ?? "Admin",
INLINE_MAX_BYTES: parseInt(env.INLINE_MAX_BYTES ?? "", 10) || 524288,
MAX_UPLOAD_BYTES:
parseInt(env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024,
MAX_USER_UPLOAD_BYTES:
parseInt(env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024,
TRUSTED_PROXY: !!env.TRUSTED_PROXY,
RATE_LIMIT_DISABLED: !!env.RATE_LIMIT_DISABLED,
SSH_DISABLED: !!env.SSH_DISABLED,
PORT: parseInt(env.PORT ?? "", 10) || 3000,
SSH_PORT: parseInt(env.SSH_PORT ?? "", 10) || 2222,
REGISTRATION_TYPE: (env.REGISTRATION_TYPE ?? "enabled") as
| "enabled"
| "disabled"
| "queue",
REGISTER_QUESTION: env.REGISTER_QUESTION ?? "",
BASE_URL:
env.BASE_URL ??
`http://localhost:${parseInt(env.PORT ?? "", 10) || 3000}`,
DATA_DIR: path.resolve(env.DATA_DIR ?? "./data"),
HIGHLIGHT_WORKERS: parseInt(env.HIGHLIGHT_WORKERS ?? "", 10) || 4,
COMMITTER_NAME: env.COMMITTER_NAME ?? env.OWNER_DISPLAY_NAME ?? "Admin",
COMMITTER_EMAIL: "",
EXTRA_ALLOWED_SIGNERS_PATH: env.EXTRA_ALLOWED_SIGNERS_PATH ?? null,
};
// Derived values that depend on other config fields
config.COMMITTER_EMAIL =
env.COMMITTER_EMAIL ??
`${config.OWNER_DISPLAY_NAME}@${new URL(config.BASE_URL).hostname}`;
export default config;
Msrc/constants.ts
@@ -1,8 +1,8 @@
import path from "node:path";
import { DATA_DIR, OWNER_DISPLAY_NAME } from "./config.ts";
import config from "./config.ts";
// Auth / identity
export const WEBAUTHN_RP_NAME = `${OWNER_DISPLAY_NAME}'s Hearthforge`;
export const WEBAUTHN_RP_NAME = `${config.OWNER_DISPLAY_NAME}'s Hearthforge`;
export const ADMIN_USERNAME = "admin";
export const VALID_USERNAME_RE = /^[a-zA-Z0-9_-]+$/;
export const VALID_REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/;
@@ -39,11 +39,27 @@ export const MAX_FILE_CACHE = 500;
export const MAX_DIFF_CACHE = 500;
export const MAX_PATCH_CACHE = 100;
// Paths
export const DB_PATH = path.join(DATA_DIR, "hearthforge.db");
export const REPOS_DIR = path.join(DATA_DIR, "repos");
export const AVATARS_DIR = path.join(DATA_DIR, "avatars");
export const RELEASES_DIR = path.join(DATA_DIR, "releases");
export const SSH_HOST_KEY_PATH =
process.env.SSH_HOST_KEY_PATH ?? path.join(DATA_DIR, "ssh_host_key");
export const ALLOWED_SIGNERS_PATH = path.join(DATA_DIR, "allowed_signers");
// Paths — derived from config.DATA_DIR via getters so they reflect overrides.
export const paths = {
get DB_PATH() {
return path.join(config.DATA_DIR, "hearthforge.db");
},
get REPOS_DIR() {
return path.join(config.DATA_DIR, "repos");
},
get AVATARS_DIR() {
return path.join(config.DATA_DIR, "avatars");
},
get RELEASES_DIR() {
return path.join(config.DATA_DIR, "releases");
},
get SSH_HOST_KEY_PATH() {
return (
process.env.SSH_HOST_KEY_PATH ??
path.join(config.DATA_DIR, "ssh_host_key")
);
},
get ALLOWED_SIGNERS_PATH() {
return path.join(config.DATA_DIR, "allowed_signers");
},
};
Msrc/db/index.ts
@@ -2,7 +2,7 @@ import { Database as BunDatabase } from "bun:sqlite";
import { type Generated, Kysely, type Selectable } from "kysely";
import { BunSqliteDialect } from "kysely-bun-sqlite";
import { DB_PATH } from "../constants.ts";
import { paths } from "../constants.ts";
interface UserTable {
id: Generated<number>;
@@ -190,7 +190,7 @@ export type ReleaseRow = Selectable<ReleaseTable>;
export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
export type LabelRow = Selectable<LabelTable>;
const sqlite = new BunDatabase(DB_PATH);
let sqlite = new BunDatabase(paths.DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA foreign_keys=ON");
@@ -220,10 +220,23 @@ sqlite.run(
"UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''",
);
export const db = new Kysely<Database>({
export let db = new Kysely<Database>({
dialect: new BunSqliteDialect({ database: sqlite }),
});
/** Close the current DB and reopen from disk (used by tests after data wipe). */
export function resetDb() {
try {
sqlite.close();
} catch {}
sqlite = new BunDatabase(paths.DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA foreign_keys=ON");
db = new Kysely<Database>({
dialect: new BunSqliteDialect({ database: sqlite }),
});
}
// Migration: add allow_user_labels column to repositories if missing
const repoCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(repositories)")
Msrc/db/init.ts
@@ -2,20 +2,15 @@ import { Database } from "bun:sqlite";
import { mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import * as argon2 from "argon2";
import { DATA_DIR } from "../config.ts";
import {
ADMIN_USERNAME,
AVATARS_DIR,
DB_PATH,
REPOS_DIR,
} from "../constants.ts";
import config from "../config.ts";
import { ADMIN_USERNAME, paths } from "../constants.ts";
// Ensure data directories exist
mkdirSync(DATA_DIR, { recursive: true });
mkdirSync(REPOS_DIR, { recursive: true });
mkdirSync(AVATARS_DIR, { recursive: true });
mkdirSync(config.DATA_DIR, { recursive: true });
mkdirSync(paths.REPOS_DIR, { recursive: true });
mkdirSync(paths.AVATARS_DIR, { recursive: true });
const db = new Database(DB_PATH);
const db = new Database(paths.DB_PATH);
db.run("PRAGMA journal_mode=WAL");
db.run("PRAGMA foreign_keys=ON");
@@ -47,5 +42,5 @@ if (!existing) {
console.log("Admin account already exists.");
}
console.log("Database initialized at", DB_PATH);
console.log("Database initialized at", paths.DB_PATH);
db.close();
Msrc/index.tsx
@@ -1,16 +1,5 @@
import path from "node:path";
import { staticPlugin } from "@elysiajs/static";
import { Elysia } from "elysia";
import { MAX_UPLOAD_BYTES, PORT, SSH_DISABLED } from "./config.ts";
import { authRoutes } from "./routes/auth.tsx";
import { avatarRoutes } from "./routes/avatars.ts";
import { gitRoutes } from "./routes/git.ts";
import { issueRoutes } from "./routes/issues.tsx";
import { patchRoutes } from "./routes/patches.tsx";
import { releasesRoutes } from "./routes/releases.tsx";
import { repoRoutes } from "./routes/repos.tsx";
import { settingsRoutes } from "./routes/settings.tsx";
import { syncStartup } from "./services/repoSync.ts";
import { createApp } from "./app.ts";
import config from "./config.ts";
import { startSshServer } from "./services/sshServer.ts";
for (const cmd of ["git", "ssh-keygen"]) {
@@ -21,22 +10,6 @@ for (const cmd of ["git", "ssh-keygen"]) {
}
}
await syncStartup();
if (!SSH_DISABLED) await startSshServer();
new Elysia({ serve: { maxRequestBodySize: MAX_UPLOAD_BYTES } })
.use(
staticPlugin({
assets: path.resolve("./public"),
prefix: "/",
}),
)
.use(gitRoutes)
.use(settingsRoutes)
.use(authRoutes)
.use(repoRoutes)
.use(issueRoutes)
.use(patchRoutes)
.use(releasesRoutes)
.use(avatarRoutes)
.listen(PORT);
console.log(`Hearthforge running at http://localhost:${PORT}`);
if (!config.SSH_DISABLED) await startSshServer();
await createApp(config.PORT);
console.log(`Hearthforge running at http://localhost:${config.PORT}`);
Msrc/lib/rateLimiter.ts
@@ -1,4 +1,4 @@
import { RATE_LIMIT_DISABLED, TRUSTED_PROXY } from "../config.ts";
import config from "../config.ts";
interface Bucket {
count: number;
@@ -12,7 +12,7 @@ export function checkRateLimit(
maxRequests: number,
windowMs: number,
): boolean {
if (RATE_LIMIT_DISABLED || !ip) return true;
if (config.RATE_LIMIT_DISABLED || !ip) return true;
const now = Date.now();
const bucket = buckets.get(ip);
if (!bucket || now > bucket.resetAt) {
@@ -28,7 +28,7 @@ export function getClientIp(
request: Request,
server: Bun.Server<unknown> | null,
): string | null {
if (TRUSTED_PROXY) {
if (config.TRUSTED_PROXY) {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
null
Msrc/lib/users.ts
@@ -1,8 +1,8 @@
import { OWNER_DISPLAY_NAME } from "../config.ts";
import config from "../config.ts";
import { ADMIN_USERNAME } from "../constants.ts";
/** Show OWNER_DISPLAY_NAME for the admin account, [Deleted user] for null, otherwise the raw username. */
export function displayName(username: string | null | undefined): string {
if (!username) return "[Deleted user]";
return username === ADMIN_USERNAME ? OWNER_DISPLAY_NAME : username;
return username === ADMIN_USERNAME ? config.OWNER_DISPLAY_NAME : username;
}
Msrc/routes/auth.tsx
@@ -6,7 +6,7 @@ import {
} from "@simplewebauthn/server";
import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { REGISTER_QUESTION, REGISTRATION_TYPE } from "../config.ts";
import config from "../config.ts";
import {
ADMIN_USERNAME,
CHALLENGE_TTL_MS,
@@ -130,15 +130,15 @@ export const authRoutes = new Elysia()
)
.get("/register", () => {
if (REGISTRATION_TYPE === "disabled")
if (config.REGISTRATION_TYPE === "disabled")
return new Response("Registration is disabled", { status: 403 });
return html(<Register question={REGISTER_QUESTION} />);
return html(<Register question={config.REGISTER_QUESTION} />);
})
.post(
"/register",
async ({ body, request, server }) => {
if (REGISTRATION_TYPE === "disabled")
if (config.REGISTRATION_TYPE === "disabled")
return new Response("Registration is disabled", {
status: 403,
});
@@ -147,7 +147,7 @@ export const authRoutes = new Elysia()
return html(
<Register
error="Too many registration attempts. Please try again later."
question={REGISTER_QUESTION}
question={config.REGISTER_QUESTION}
/>,
);
}
@@ -157,7 +157,7 @@ export const authRoutes = new Elysia()
return html(
<Register
error="Username may only contain letters, numbers, hyphens, and underscores"
question={REGISTER_QUESTION}
question={config.REGISTER_QUESTION}
/>,
);
}
@@ -165,7 +165,7 @@ export const authRoutes = new Elysia()
return html(
<Register
error="That username is reserved"
question={REGISTER_QUESTION}
question={config.REGISTER_QUESTION}
/>,
);
}
@@ -174,7 +174,7 @@ export const authRoutes = new Elysia()
return html(
<Register
error="Password is required (use the passkey button for passwordless registration)"
question={REGISTER_QUESTION}
question={config.REGISTER_QUESTION}
/>,
);
}
@@ -182,7 +182,7 @@ export const authRoutes = new Elysia()
return html(
<Register
error="Passwords do not match"
question={REGISTER_QUESTION}
question={config.REGISTER_QUESTION}
/>,
);
}
@@ -190,14 +190,14 @@ export const authRoutes = new Elysia()
return html(
<Register
error="Password must be at least 8 characters"
question={REGISTER_QUESTION}
question={config.REGISTER_QUESTION}
/>,
);
}
const hash = await argon2.hash(password);
const now = new Date().toISOString();
const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0;
const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0;
let result: { id: number };
try {
result = await db
@@ -221,16 +221,19 @@ export const authRoutes = new Elysia()
return html(
<Register
error="Username already taken"
question={REGISTER_QUESTION}
question={config.REGISTER_QUESTION}
/>,
);
}
throw err;
}
if (REGISTRATION_TYPE === "queue") {
if (config.REGISTRATION_TYPE === "queue") {
return html(
<Register pending={true} question={REGISTER_QUESTION} />,
<Register
pending={true}
question={config.REGISTER_QUESTION}
/>,
);
}
@@ -262,7 +265,7 @@ export const authRoutes = new Elysia()
.post(
"/auth/passkey/create-user",
async ({ body }) => {
if (REGISTRATION_TYPE === "disabled") {
if (config.REGISTRATION_TYPE === "disabled") {
return new Response(
JSON.stringify({ error: "Registration is disabled" }),
{ status: 400 },
@@ -284,7 +287,7 @@ export const authRoutes = new Elysia()
);
}
const now = new Date().toISOString();
const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0;
const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0;
let result: { id: number };
try {
result = await db
@@ -313,7 +316,7 @@ export const authRoutes = new Elysia()
throw err;
}
if (REGISTRATION_TYPE === "queue") {
if (config.REGISTRATION_TYPE === "queue") {
return new Response(
JSON.stringify({ ok: true, pending: true }),
{ headers: { "Content-Type": "application/json" } },
Msrc/routes/avatars.ts
@@ -1,5 +1,5 @@
import { Elysia, t } from "elysia";
import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
import config from "../config.ts";
import { db } from "../db/index.ts";
import { requireAuth, resolveSession } from "../middleware/session.ts";
import {
@@ -55,7 +55,7 @@ export const avatarRoutes = new Elysia()
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
if (body.avatar.size > MAX_USER_UPLOAD_BYTES) {
if (body.avatar.size > config.MAX_USER_UPLOAD_BYTES) {
return new Response("Avatar file too large", { status: 400 });
}
const buffer = Buffer.from(await body.avatar.arrayBuffer());
Msrc/routes/git.ts
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
import path from "node:path";
import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { ADMIN_USERNAME, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
import { db } from "../db";
function pktLine(str: string): Buffer {
@@ -59,7 +59,7 @@ async function getRepo(
.where("name", "=", repoName)
.executeTakeFirst();
if (!repo) return null;
const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`);
if (!existsSync(repoPath)) return null;
return { repoPath, isPrivate: repo.is_private === 1 };
}
Msrc/routes/patches.tsx
@@ -1,10 +1,6 @@
import { Elysia, t } from "elysia";
import { sql } from "kysely";
import {
COMMITTER_EMAIL,
COMMITTER_NAME,
MAX_USER_UPLOAD_BYTES,
} from "../config.ts";
import config from "../config.ts";
import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
import { db, getRepo, type LabelRow } from "../db/index.ts";
import {
@@ -293,7 +289,7 @@ export const patchRoutes = new Elysia()
);
}
if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
if (body.patch_file.size > config.MAX_USER_UPLOAD_BYTES) {
return html(
<NewPatch
user={user!}
@@ -637,8 +633,8 @@ export const patchRoutes = new Elysia()
patch.patch_content,
mergeMeta.author,
mergeMeta.email,
COMMITTER_NAME,
COMMITTER_EMAIL,
config.COMMITTER_NAME,
config.COMMITTER_EMAIL,
);
} catch (err) {
// Roll back the status if the git operation fails
@@ -689,7 +685,7 @@ export const patchRoutes = new Elysia()
if (!body.patch_file || body.patch_file.size === 0) {
return new Response("Patch file is required", { status: 400 });
}
if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
if (body.patch_file.size > config.MAX_USER_UPLOAD_BYTES) {
return new Response("Patch file is too large", { status: 400 });
}
Msrc/routes/releases.tsx
@@ -1,8 +1,8 @@
import { mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts";
import config from "../config.ts";
import { paths, RELEASES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { archiveRepo, git } from "../services/git.ts";
@@ -184,8 +184,8 @@ export const releasesRoutes = new Elysia()
tagName,
revision,
tagMessage,
COMMITTER_NAME,
COMMITTER_EMAIL,
config.COMMITTER_NAME,
config.COMMITTER_EMAIL,
);
if (tagResult === "already_exists") {
return html(
@@ -268,7 +268,7 @@ export const releasesRoutes = new Elysia()
.executeTakeFirstOrThrow();
const id = inserted.id;
releaseDir = path.join(RELEASES_DIR, String(id));
releaseDir = path.join(paths.RELEASES_DIR, String(id));
if (includeSource) {
const sourceDir = path.join(releaseDir, "source");
@@ -320,7 +320,7 @@ export const releasesRoutes = new Elysia()
// is no risk of contaminating a later release.
if (includeSource) {
const sourceDir = path.join(
RELEASES_DIR,
paths.RELEASES_DIR,
String(releaseId),
"source",
);
@@ -407,7 +407,7 @@ export const releasesRoutes = new Elysia()
if (release.include_source_code) {
const base = `${repo.name}-${release.tag_name}`;
const sourceDir = path.join(
RELEASES_DIR,
paths.RELEASES_DIR,
String(release.id),
"source",
);
@@ -477,7 +477,10 @@ export const releasesRoutes = new Elysia()
// Remove files from disk before the DB record so that a crash
// between the two leaves a broken-but-visible repo rather than a
// DB record pointing to missing files.
const releaseDir = path.join(RELEASES_DIR, String(release.id));
const releaseDir = path.join(
paths.RELEASES_DIR,
String(release.id),
);
rmSync(releaseDir, { recursive: true, force: true });
await db
.deleteFrom("releases")
@@ -522,7 +525,7 @@ export const releasesRoutes = new Elysia()
if (!asset) return new Response("Not found", { status: 404 });
const filePath = path.join(
RELEASES_DIR,
paths.RELEASES_DIR,
String(release.id),
"assets",
safeFilename,
@@ -565,7 +568,7 @@ export const releasesRoutes = new Elysia()
const safeFilename = path.basename(params.filename);
const filePath = path.join(
RELEASES_DIR,
paths.RELEASES_DIR,
String(release.id),
"source",
safeFilename,
Msrc/routes/repos.tsx
@@ -2,10 +2,10 @@ import { readFileSync, rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { fileTypeFromBuffer } from "file-type";
import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
import config from "../config.ts";
import {
ALLOWED_SIGNERS_PATH,
COMMITS_PER_PAGE,
paths,
REPOS_PER_PAGE,
VALID_REPO_NAME_RE,
} from "../constants.ts";
@@ -78,7 +78,7 @@ async function readReadme(
export const repoRoutes = new Elysia()
.get("/allowed_signers", () => {
return new Response(readFileSync(ALLOWED_SIGNERS_PATH), {
return new Response(readFileSync(paths.ALLOWED_SIGNERS_PATH), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
})
@@ -593,8 +593,8 @@ export const repoRoutes = new Elysia()
filePath,
content,
message,
COMMITTER_NAME,
COMMITTER_EMAIL,
config.COMMITTER_NAME,
config.COMMITTER_EMAIL,
);
return new Response(null, {
Msrc/routes/settings.tsx
@@ -1,7 +1,7 @@
import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { utils as sshUtils } from "ssh2";
import { REGISTRATION_TYPE } from "../config.ts";
import config from "../config.ts";
import {
ADMIN_USERNAME,
VALID_KEY_TYPES,
@@ -55,7 +55,7 @@ export const settingsRoutes = new Elysia()
const hasPassword = !!userRow?.password_hash;
const pendingUsers =
user.isAdmin && REGISTRATION_TYPE === "queue"
user.isAdmin && config.REGISTRATION_TYPE === "queue"
? await db
.selectFrom("users")
.select([
Msrc/services/avatar.ts
@@ -3,9 +3,9 @@ import path from "node:path";
import { encode } from "@jsquash/jxl";
import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";
import { AVATARS_DIR } from "../constants.ts";
import { paths } from "../constants.ts";
mkdirSync(AVATARS_DIR, { recursive: true });
mkdirSync(paths.AVATARS_DIR, { recursive: true });
// FNV-1a: produces n deterministic bytes from a string
function hashBytes(s: string, n: number): number[] {
@@ -41,7 +41,7 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
}
export function avatarJxlPath(userId: number): string {
return path.join(AVATARS_DIR, `${userId}.jxl`);
return path.join(paths.AVATARS_DIR, `${userId}.jxl`);
}
export async function processAndStoreAvatar(
Msrc/services/diffHighlight.ts
@@ -1,6 +1,6 @@
import path from "node:path";
import type { BundledLanguage } from "shiki";
import { INLINE_MAX_BYTES } from "../config.ts";
import config from "../config.ts";
import { git } from "./git.ts";
import { detectLang, getHighlighter } from "./highlight.ts";
@@ -271,7 +271,7 @@ export async function highlightFile(
0,
);
const highlightedHunks =
totalBytes > INLINE_MAX_BYTES
totalBytes > config.INLINE_MAX_BYTES
? file.hunks.map((hunk) =>
hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)),
)
Msrc/services/git.ts
@@ -1,11 +1,7 @@
import path from "node:path";
import { $ as _$ } from "bun";
import {
ALLOWED_SIGNERS_PATH,
REPOS_DIR,
SSH_HOST_KEY_PATH,
} from "../constants.ts";
import { paths } from "../constants.ts";
const gitEnv = {
...process.env,
@@ -45,7 +41,7 @@ async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
}
export function repoPath(name: string): string {
return path.join(REPOS_DIR, `${name}.git`);
return path.join(paths.REPOS_DIR, `${name}.git`);
}
export async function validateCommit(
@@ -268,7 +264,7 @@ export const git = {
"-c",
"gpg.format=ssh",
"-c",
`gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
`gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
];
try {
const out =
@@ -465,7 +461,7 @@ export const git = {
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${SSH_HOST_KEY_PATH}`,
`user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
];
const commit = (
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
@@ -515,7 +511,7 @@ export const git = {
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${SSH_HOST_KEY_PATH}`,
`user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
];
const commit = (
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
@@ -550,7 +546,7 @@ export const git = {
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${SSH_HOST_KEY_PATH}`,
`user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
];
const result =
message !== undefined
@@ -606,7 +602,7 @@ export const git = {
"-c",
"gpg.format=ssh",
"-c",
`gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
`gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
];
try {
const [metaOut, msgOut] = await Promise.all([
Msrc/services/highlight.ts
@@ -7,7 +7,7 @@ import {
createHighlighter,
type Highlighter,
} from "shiki";
import { INLINE_MAX_BYTES } from "../config.ts";
import config from "../config.ts";
import { MAX_FILE_CACHE } from "../constants.ts";
let highlighter: Highlighter | null = null;
@@ -170,7 +170,7 @@ export async function serveFile(
if (isBinary) {
return { type: "binary", size: content.length };
}
if (content.length > INLINE_MAX_BYTES) {
if (content.length > config.INLINE_MAX_BYTES) {
return { type: "download", size: content.length };
}
const text = content.toString("utf-8");
Msrc/services/highlightWorker.ts
@@ -1,4 +1,4 @@
import { HIGHLIGHT_WORKERS } from "../config.ts";
import config from "../config.ts";
import { MAX_DIFF_CACHE } from "../constants.ts";
import type { ParsedFile, RenderedDiffFile } from "./diffHighlight.ts";
import { parseDiff } from "./diffHighlight.ts";
@@ -59,7 +59,7 @@ function onMessage(handle: WorkerHandle, event: MessageEvent) {
}
const pool: WorkerHandle[] = Array.from(
{ length: HIGHLIGHT_WORKERS },
{ length: config.HIGHLIGHT_WORKERS },
createHandle,
);
Msrc/services/repoSync.ts
@@ -8,14 +8,8 @@ import {
} from "node:fs";
import path from "node:path";
import { $ } from "bun";
import { BASE_URL, EXTRA_ALLOWED_SIGNERS_PATH } from "../config.ts";
import {
ALLOWED_SIGNERS_PATH,
RELEASES_DIR,
REPOS_DIR,
SSH_HOST_KEY_PATH,
VALID_REPO_NAME_RE,
} from "../constants.ts";
import config from "../config.ts";
import { paths, VALID_REPO_NAME_RE } from "../constants.ts";
import type { RepositoryRow } from "../db/index.ts";
import { db } from "../db/index.ts";
import { git, repoPath } from "../services/git.ts";
@@ -30,12 +24,15 @@ async function convertNonBareRepo(
const dotGitPath = path.join(entryPath, ".git");
const hasGitSuffix = entryName.endsWith(".git");
const baseName = hasGitSuffix ? entryName : `${entryName}.git`;
const targetPath = path.join(REPOS_DIR, baseName);
const targetPath = path.join(paths.REPOS_DIR, baseName);
try {
if (hasGitSuffix) {
// Case 2: source and target path are the same dir — use a temp location.
const tmpPath = path.join(REPOS_DIR, `.${entryName}.bare_tmp`);
const tmpPath = path.join(
paths.REPOS_DIR,
`.${entryName}.bare_tmp`,
);
renameSync(dotGitPath, tmpPath);
rmSync(entryPath, { recursive: true, force: true });
renameSync(tmpPath, targetPath);
@@ -56,11 +53,11 @@ async function convertNonBareRepo(
}
}
// Scans REPOS_DIR for non-bare repos and converts them before the main sync.
// Scans paths.REPOS_DIR for non-bare repos and converts them before the main sync.
async function convertNonBareRepos(): Promise<void> {
let entries: Dirent[];
try {
entries = readdirSync(REPOS_DIR, { withFileTypes: true });
entries = readdirSync(paths.REPOS_DIR, { withFileTypes: true });
} catch {
return;
}
@@ -68,7 +65,7 @@ async function convertNonBareRepos(): Promise<void> {
const conversions: Promise<void>[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const entryPath = path.join(REPOS_DIR, entry.name);
const entryPath = path.join(paths.REPOS_DIR, entry.name);
if (existsSync(path.join(entryPath, ".git"))) {
conversions.push(convertNonBareRepo(entry.name, entryPath));
}
@@ -78,7 +75,7 @@ async function convertNonBareRepos(): Promise<void> {
export function listDiskRepoNames(): string[] {
try {
return readdirSync(REPOS_DIR, { withFileTypes: true })
return readdirSync(paths.REPOS_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.endsWith(".git"))
.map((e) => e.name.slice(0, -4));
} catch {
@@ -115,22 +112,26 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
}
async function ensureSigningSetup(): Promise<void> {
const hostname = new URL(BASE_URL).hostname;
const pubKeyPath = `${SSH_HOST_KEY_PATH}.pub`;
const hostname = new URL(config.BASE_URL).hostname;
const pubKeyPath = `${paths.SSH_HOST_KEY_PATH}.pub`;
if (!existsSync(SSH_HOST_KEY_PATH)) {
await Bun.spawn([
if (!existsSync(paths.SSH_HOST_KEY_PATH)) {
const { spawnSync } = await import("node:child_process");
spawnSync(
"ssh-keygen",
"-t",
"ed25519",
"-N",
"",
"-f",
SSH_HOST_KEY_PATH,
"-C",
hostname,
]).exited;
console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
[
"-t",
"ed25519",
"-N",
"",
"-f",
paths.SSH_HOST_KEY_PATH,
"-C",
hostname,
],
{ stdio: "ignore" },
);
console.log("Generated SSH host key at", paths.SSH_HOST_KEY_PATH);
} else {
try {
const existing = readFileSync(pubKeyPath, "utf8").trim();
@@ -158,19 +159,22 @@ async function ensureSigningSetup(): Promise<void> {
let content = `* namespaces="git" ${pubKey}\n`;
if (EXTRA_ALLOWED_SIGNERS_PATH) {
if (config.EXTRA_ALLOWED_SIGNERS_PATH) {
try {
const extra = readFileSync(EXTRA_ALLOWED_SIGNERS_PATH, "utf8");
const extra = readFileSync(
config.EXTRA_ALLOWED_SIGNERS_PATH,
"utf8",
);
content += extra.endsWith("\n") ? extra : `${extra}\n`;
} catch {
console.warn(
"Could not read EXTRA_ALLOWED_SIGNERS_PATH:",
EXTRA_ALLOWED_SIGNERS_PATH,
"Could not read config.EXTRA_ALLOWED_SIGNERS_PATH:",
config.EXTRA_ALLOWED_SIGNERS_PATH,
);
}
}
await Bun.write(ALLOWED_SIGNERS_PATH, content);
await Bun.write(paths.ALLOWED_SIGNERS_PATH, content);
}
export async function syncStartup(): Promise<void> {
@@ -219,13 +223,13 @@ export async function syncStartup(): Promise<void> {
);
try {
for (const entry of readdirSync(RELEASES_DIR, {
for (const entry of readdirSync(paths.RELEASES_DIR, {
withFileTypes: true,
})) {
if (!entry.isDirectory()) continue;
const id = Number(entry.name);
if (!Number.isNaN(id) && !allReleaseIds.has(id)) {
rmSync(path.join(RELEASES_DIR, entry.name), {
rmSync(path.join(paths.RELEASES_DIR, entry.name), {
recursive: true,
force: true,
});
@@ -235,7 +239,7 @@ export async function syncStartup(): Promise<void> {
}
}
} catch {
// RELEASES_DIR may not exist yet on first run
// paths.RELEASES_DIR may not exist yet on first run
}
// Only check for missing dirs on releases that should have one.
@@ -260,7 +264,7 @@ export async function syncStartup(): Promise<void> {
.execute();
const staleReleases = releasesWithDirs.filter(
(r) => !existsSync(path.join(RELEASES_DIR, String(r.id))),
(r) => !existsSync(path.join(paths.RELEASES_DIR, String(r.id))),
);
if (staleReleases.length > 0) {
await db
Msrc/services/sshServer.ts
@@ -3,8 +3,8 @@ import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { Server, utils } from "ssh2";
import { SSH_PORT } from "../config.ts";
import { ADMIN_USERNAME, REPOS_DIR, SSH_HOST_KEY_PATH } from "../constants.ts";
import config from "../config.ts";
import { ADMIN_USERNAME, paths } from "../constants.ts";
import { db } from "../db/index.ts";
/** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
@@ -33,7 +33,7 @@ export function fingerprintFromLine(pubkeyLine: string): string | null {
}
export async function startSshServer() {
const hostKey = readFileSync(SSH_HOST_KEY_PATH);
const hostKey = readFileSync(paths.SSH_HOST_KEY_PATH);
const server = new Server({ hostKeys: [hostKey] }, (client) => {
let authedUser: { id: number; username: string } | null = null;
@@ -94,7 +94,10 @@ export async function startSshServer() {
.executeTakeFirst();
if (!repo) return reject();
const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
const repoPath = path.join(
paths.REPOS_DIR,
`${repo.name}.git`,
);
const stream = accept();
if (command === "git-receive-pack") {
@@ -139,7 +142,7 @@ export async function startSshServer() {
});
});
server.listen(SSH_PORT, "0.0.0.0", () => {
console.log(`SSH server listening on port ${SSH_PORT}`);
server.listen(config.SSH_PORT, "0.0.0.0", () => {
console.log(`SSH server listening on port ${config.SSH_PORT}`);
});
}
Asrc/styles/base.css
@@ -0,0 +1,309 @@
/* View Transitions */
@view-transition {
navigation: auto;
}
.site-nav {
view-transition-name: site-nav;
}
.repo-header {
view-transition-name: repo-header;
}
.breadcrumb {
view-transition-name: breadcrumb;
}
/* ============================================================
@layer reset
============================================================ */
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100dvh;
min-width: 500px;
}
img,
video {
max-width: 100%;
height: auto;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
}
ul,
ol {
list-style: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.2;
}
pre,
code {
font-family: var(--font-mono);
}
}
/* ============================================================
@layer tokens
============================================================ */
@layer tokens {
:root {
/* Colors — light */
--color-bg: #ffffff;
--color-bg-subtle: #f6f8fa;
--color-bg-inset: #eef0f2;
--color-border: #d0d7de;
--color-border-muted: #e4e7eb;
--color-text: #1f2328;
--color-text-muted: #656d76;
--color-link: #0969da;
--color-link-hover: #0550ae;
--color-accent: #fd7e14;
--color-accent-bg: #fff3cd;
--color-success: #1a7f37;
--color-success-bg: #dafbe1;
--color-danger: #cf222e;
--color-danger-bg: #ffebe9;
--color-warning: #9a6700;
--color-warning-bg: #fff8c5;
--color-merged: #8250df;
--color-merged-bg: #fbefff;
/* Typography */
--font-sans:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono:
ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
/* Border radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 0 rgba(27, 31, 36, 0.04);
--shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15);
--shadow-lg: 0 8px 24px rgba(140, 149, 159, 0.2);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0d1117;
--color-bg-subtle: #161b22;
--color-bg-inset: #1c2128;
--color-border: #30363d;
--color-border-muted: #21262d;
--color-text: #e6edf3;
--color-text-muted: #7d8590;
--color-link: #58a6ff;
--color-link-hover: #79c0ff;
--color-accent: #f0883e;
--color-accent-bg: #3d2f00;
--color-success: #3fb950;
--color-success-bg: #0d2b19;
--color-danger: #f85149;
--color-danger-bg: #2d1419;
--color-warning: #d29922;
--color-warning-bg: #2c2000;
--color-merged: #bc8cff;
--color-merged-bg: #1e0b3e;
}
}
/* Explicit theme overrides — applied via data-theme attribute set by cookie */
:root[data-theme="dark"] {
--color-bg: #0d1117;
--color-bg-subtle: #161b22;
--color-bg-inset: #1c2128;
--color-border: #30363d;
--color-border-muted: #21262d;
--color-text: #e6edf3;
--color-text-muted: #7d8590;
--color-link: #58a6ff;
--color-link-hover: #79c0ff;
--color-accent: #f0883e;
--color-accent-bg: #3d2f00;
--color-success: #3fb950;
--color-success-bg: #0d2b19;
--color-danger: #f85149;
--color-danger-bg: #2d1419;
--color-warning: #d29922;
--color-warning-bg: #2c2000;
--color-merged: #bc8cff;
--color-merged-bg: #1e0b3e;
}
:root[data-theme="light"] {
--color-bg: #ffffff;
--color-bg-subtle: #f6f8fa;
--color-bg-inset: #eef0f2;
--color-border: #d0d7de;
--color-border-muted: #e4e7eb;
--color-text: #1f2328;
--color-text-muted: #656d76;
--color-link: #0969da;
--color-link-hover: #0550ae;
--color-accent: #fd7e14;
--color-accent-bg: #fff3cd;
--color-success: #1a7f37;
--color-success-bg: #dafbe1;
--color-danger: #cf222e;
--color-danger-bg: #ffebe9;
--color-warning: #9a6700;
--color-warning-bg: #fff8c5;
--color-merged: #8250df;
--color-merged-bg: #fbefff;
}
}
/* ============================================================
@layer layout
============================================================ */
@layer layout {
body {
font-family: var(--font-sans);
font-size: var(--text-base);
color: var(--color-text);
background: var(--color-bg);
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
}
.site-header {
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
}
.site-nav {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
gap: var(--space-4);
}
.site-logo {
display: flex;
align-items: center;
gap: var(--space-2);
text-decoration: none;
font-weight: 700;
font-size: var(--text-lg);
color: var(--color-text);
}
.logo-icon {
width: 1.6em;
height: 1.6em;
display: block;
flex-shrink: 0;
}
.nav-links {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-3);
}
.site-main {
padding: var(--space-8) var(--space-4);
min-width: 0;
}
.site-footer {
text-align: center;
padding: var(--space-6) var(--space-4);
font-size: var(--text-sm);
color: var(--color-text-muted);
border-top: 1px solid var(--color-border);
}
.container {
max-width: 1000px;
margin: 0 auto;
overflow-x: clip;
}
.container-narrow {
max-width: 680px;
}
.inline-form {
display: inline;
}
/* --- Two-step delete confirmation --- */
.confirm-details {
position: relative;
display: inline-block;
}
.confirm-details > summary {
list-style: none;
}
.confirm-popup {
position: absolute;
right: 0;
top: calc(100% + var(--space-1));
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
white-space: nowrap;
z-index: 100;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
}
Asrc/styles/code.css
@@ -0,0 +1,714 @@
@layer components {
/* --- File tree --- */
.file-tree {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
border-collapse: collapse;
overflow: hidden;
margin-bottom: var(--space-6);
}
.file-tree-row {
border-bottom: 1px solid var(--color-border-muted);
}
.file-tree-row:last-child {
border-bottom: none;
}
.file-tree-row:hover {
background: var(--color-bg-subtle);
}
.file-icon,
.file-name,
.file-size {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
vertical-align: middle;
}
.file-icon {
width: 36px;
padding-right: var(--space-1);
text-align: center;
line-height: 0;
}
.file-name {
width: 100%;
}
.file-name a {
color: var(--color-link);
text-decoration: none;
}
.file-name a:hover {
text-decoration: underline;
}
.file-name a.file-name-dir {
font-weight: 500;
}
.file-size {
text-align: right;
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: nowrap;
padding-right: var(--space-4);
}
.file-icon-dir {
color: var(--color-link);
}
.file-icon-file {
color: var(--color-text-muted);
}
.tree-icon {
display: inline-block;
vertical-align: text-bottom;
}
.file-tree-row-up .file-name a {
color: var(--color-text-muted);
}
.file-tree-row-up .file-name a:hover {
color: var(--color-link);
}
/* --- File blob --- */
.file-blob-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
margin-top: var(--space-4);
}
.file-blob-name {
font-size: var(--text-sm);
font-weight: 500;
}
.file-blob-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.file-blob-body {
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
overflow: auto;
}
.file-edit-textarea {
display: block;
width: 100%;
box-sizing: border-box;
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.6;
padding: var(--space-4);
background: var(--color-bg);
color: var(--color-text);
border: none;
resize: vertical;
min-height: 400px;
}
.file-edit-textarea:focus {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
/* Blob line-number table */
.blob-table {
min-width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.6;
background-color: var(--shiki-light-bg, #fff);
color: var(--shiki-light, inherit);
}
.blob-ln {
width: 1%;
min-width: 48px;
text-align: right;
padding: 0 var(--space-3);
color: var(--color-text-muted);
border-right: 1px solid var(--color-border-muted);
user-select: none;
white-space: nowrap;
vertical-align: top;
}
.blob-ln a {
color: inherit;
text-decoration: none;
}
.blob-ln a:hover {
color: var(--color-link);
}
.blob-code {
padding: 0 var(--space-4);
white-space: pre;
overflow: visible;
vertical-align: top;
}
.blob-table tr:target {
background: color-mix(in srgb, #ddf4ff 60%, transparent);
}
.blob-table tr:target .blob-ln {
background: color-mix(in srgb, #ddf4ff 80%, transparent);
}
.file-download-notice {
padding: var(--space-8);
text-align: center;
color: var(--color-text-muted);
}
.file-download-notice p {
margin-bottom: var(--space-4);
}
.file-media {
padding: var(--space-4);
display: flex;
justify-content: center;
background: var(--color-bg-subtle);
}
.file-media-img {
max-width: 100%;
height: auto;
display: block;
}
.file-media-audio {
width: 100%;
max-width: 600px;
}
.file-media-video {
max-width: 100%;
max-height: 80vh;
}
/* --- Commit list --- */
.commit-list {
display: flex;
flex-direction: column;
}
.commit-item {
display: flex;
flex-direction: column;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border-muted);
gap: var(--space-2);
}
.commit-item:last-child {
border-bottom: none;
}
.commit-subject {
color: var(--color-text);
text-decoration: none;
font-size: var(--text-sm);
font-weight: 500;
}
.commit-subject:hover {
color: var(--color-link);
}
.commit-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.commit-meta-right {
display: flex;
align-items: center;
gap: var(--space-3);
white-space: nowrap;
}
.commit-hash {
font-family: var(--font-mono);
color: var(--color-link);
text-decoration: none;
background: var(--color-bg-inset);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
}
.commit-hash:hover {
text-decoration: underline;
}
.mono {
font-family: var(--font-mono);
}
/* --- Commit detail viewer --- */
.commit-page-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.commit-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.commit-card-subject {
font-size: var(--text-xl);
font-weight: 600;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
line-height: 1.3;
}
.commit-card-subject-empty {
color: var(--color-text-muted);
font-style: italic;
font-weight: 400;
font-size: var(--text-sm);
}
.commit-card-body {
padding: var(--space-4) var(--space-6);
font-family: inherit;
font-size: var(--text-sm);
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.commit-card-meta {
padding: var(--space-4) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-2);
background: var(--color-bg-subtle);
}
.commit-card-meta-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
font-size: var(--text-sm);
flex-wrap: wrap;
}
.commit-meta-label {
font-weight: 600;
color: var(--color-text-muted);
min-width: 5rem;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.commit-sha-full {
font-size: var(--text-xs);
background: var(--color-bg-inset);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
word-break: break-all;
}
.commit-stats-bar {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}
/* --- Commit layout (sidebar + diffs) --- */
.commit-layout {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-5);
align-items: start;
}
.commit-diffs {
min-width: 0;
}
@media (min-width: 900px) {
.commit-layout {
grid-template-columns: 220px 1fr;
}
.commit-layout:has(.file-nav-details:not([open])) {
grid-template-columns: auto 1fr;
}
}
/* --- File nav sidebar --- */
.commit-file-nav {
position: sticky;
top: calc(56px + var(--space-4));
max-height: calc(100vh - 140px);
overflow-y: auto;
}
.file-nav-details {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-subtle);
overflow: hidden;
}
.file-nav-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
list-style: none;
user-select: none;
border-bottom: 1px solid var(--color-border);
}
.file-nav-details:not([open]) .file-nav-toggle {
border-bottom: none;
}
.file-nav-toggle-icon {
font-size: 1em;
transition: transform 0.15s;
}
.file-nav-details:not([open]) .file-nav-toggle-icon {
transform: rotate(-90deg);
}
.file-nav-list {
padding: var(--space-1) 0;
list-style: none;
}
.file-nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
text-decoration: none;
color: var(--color-text);
border-radius: 0;
overflow: hidden;
}
.file-nav-item:hover {
background: var(--color-bg-inset);
}
.file-nav-status {
flex-shrink: 0;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 9px;
font-weight: 700;
font-family: var(--font-mono);
}
.file-status-added {
background: var(--color-success-bg);
color: var(--color-success);
}
.file-status-deleted {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.file-status-modified {
background: var(--color-accent-bg);
color: var(--color-accent);
}
.file-status-renamed {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.file-status-copied {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.file-nav-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
}
.file-nav-stat {
flex-shrink: 0;
display: flex;
gap: var(--space-1);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.nav-add {
color: var(--color-success);
}
.nav-del {
color: var(--color-danger);
}
.nav-binary {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.file-nav-dir > details > .file-nav-list {
padding-left: var(--space-4);
}
.file-nav-dir-toggle {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-family: var(--font-mono);
color: var(--color-text-muted);
cursor: pointer;
list-style: none;
user-select: none;
}
.file-nav-dir > details:not([open]) .file-nav-toggle-icon {
transform: rotate(-90deg);
}
/* --- Diff file cards --- */
.diff-file {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.diff-file-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
cursor: pointer;
list-style: none;
user-select: none;
flex-wrap: wrap;
}
.diff-file-header-left {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
flex: 1;
}
.diff-file-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.diff-file-toggle-icon {
font-size: 0.7em;
flex-shrink: 0;
transition: transform 0.15s;
}
.diff-file:not([open]) .diff-file-toggle-icon {
transform: rotate(-90deg);
}
.diff-status-badge {
flex-shrink: 0;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 700;
font-family: var(--font-mono);
}
.diff-status-added {
background: var(--color-success-bg);
color: var(--color-success);
}
.diff-status-deleted {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.diff-status-modified {
background: var(--color-accent-bg);
color: var(--color-accent);
}
.diff-status-renamed {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.diff-status-copied {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.diff-file-path {
font-size: var(--text-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff-rename-arrow {
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.diff-stat-add {
color: var(--color-success);
font-size: var(--text-sm);
font-family: var(--font-mono);
font-weight: 600;
}
.diff-stat-del {
color: var(--color-danger);
font-size: var(--text-sm);
font-family: var(--font-mono);
font-weight: 600;
}
.diff-stat-binary {
color: var(--color-text-muted);
font-size: var(--text-sm);
font-family: var(--font-mono);
}
.btn-xs {
padding: 1px var(--space-2);
font-size: var(--text-xs);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
font-family: var(--font-mono);
display: inline-flex;
align-items: center;
transition: background 0.1s;
}
.btn-xs:hover {
background: var(--color-bg-inset);
}
/* --- Diff file body --- */
.diff-binary-notice {
padding: var(--space-4) var(--space-6);
font-size: var(--text-sm);
color: var(--color-text-muted);
font-style: italic;
}
.diff-hunk {
border-top: 1px solid var(--color-border-muted);
overflow-x: auto;
}
.diff-hunk:first-child {
border-top: none;
}
.diff-hunk-header {
padding: var(--space-1) var(--space-4);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: normal;
text-align: left;
background: color-mix(
in srgb,
var(--color-merged-bg) 50%,
var(--color-bg)
);
color: var(--color-merged);
border-bottom: 1px solid var(--color-border-muted);
white-space: pre;
}
/* --- Diff table --- */
.diff-table {
min-width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.5;
}
.diff-ln {
width: 44px;
min-width: 44px;
text-align: right;
padding: 0 var(--space-2);
color: var(--color-text-muted);
border-right: 1px solid var(--color-border-muted);
user-select: none;
vertical-align: top;
white-space: nowrap;
}
.diff-sign {
width: 18px;
min-width: 18px;
text-align: center;
padding: 0 var(--space-1);
user-select: none;
vertical-align: top;
}
.diff-code {
padding: 0 var(--space-3);
white-space: pre;
overflow: visible;
vertical-align: top;
word-break: normal;
}
/* Row type backgrounds */
.diff-row-add {
background: color-mix(
in srgb,
var(--color-success-bg) 70%,
transparent
);
}
.diff-row-del {
background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent);
}
.diff-row-add .diff-ln,
.diff-row-add .diff-sign {
background: color-mix(
in srgb,
var(--color-success-bg) 90%,
transparent
);
}
.diff-row-del .diff-ln,
.diff-row-del .diff-sign {
background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent);
}
.diff-row-add .diff-sign {
color: var(--color-success);
font-weight: 700;
}
.diff-row-del .diff-sign {
color: var(--color-danger);
font-weight: 700;
}
.diff-ctrl {
color: #f0f0f0;
background: #c0392b;
border-radius: 2px;
font-weight: bold;
padding: 0 1px;
}
/* Shiki dark-mode support in diff code cells */
@media (prefers-color-scheme: dark) {
.diff-code span[style] {
/* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
color: var(--shiki-dark) !important;
}
}
/* Shiki dark-mode support in file blob view */
@media (prefers-color-scheme: dark) {
.blob-table {
background-color: var(--shiki-dark-bg);
color: var(--shiki-dark);
}
.blob-table span[style] {
/* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
color: var(--shiki-dark) !important;
}
.blob-table tr:target {
background: color-mix(in srgb, #1c3a52 60%, transparent);
}
.blob-table tr:target .blob-ln {
background: color-mix(in srgb, #1c3a52 80%, transparent);
}
}
:root[data-theme="dark"] .blob-table {
background-color: var(--shiki-dark-bg);
color: var(--shiki-dark);
}
:root[data-theme="dark"] .blob-table span[style] {
/* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
color: var(--shiki-dark) !important;
}
}
Asrc/styles/components.css
@@ -0,0 +1,1798 @@
@layer components {
/* --- Buttons --- */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
cursor: pointer;
background: var(--color-bg-subtle);
color: var(--color-text);
transition:
background 0.1s,
border-color 0.1s;
white-space: nowrap;
}
.btn:hover {
background: var(--color-bg-inset);
border-color: var(--color-border);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
border-color: transparent;
color: #fff;
}
.btn-primary:hover {
background: color-mix(in srgb, var(--color-accent) 82%, #000);
}
.btn-secondary {
background: var(--color-bg-subtle);
border-color: var(--color-border);
}
.btn-sm {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
}
.btn-block {
width: 100%;
justify-content: center;
}
/* --- Forms --- */
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.form-group label,
.form-group .form-group-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text);
}
.form-group input:not([type="file"]):not([type="checkbox"]),
.form-group textarea,
.form-group select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.form-group input:not([type="file"]):not([type="checkbox"]):focus,
.form-group textarea:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.form-group input[type="file"] {
font-size: var(--text-sm);
color: var(--color-text);
padding: 0;
border: none;
background: none;
}
.form-group textarea {
resize: vertical;
min-height: 120px;
}
.form-success {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.form-hint {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin: 0;
}
.form-error {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.form-card {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
margin-top: var(--space-4);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
/* --- Page header --- */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
}
.page-title {
font-size: var(--text-2xl);
color: var(--color-text);
margin-bottom: var(--space-4);
}
.page-title a {
text-decoration: none;
color: var(--color-link);
}
.page-title a:hover {
text-decoration: underline;
}
.section-title {
font-size: var(--text-lg);
margin-bottom: var(--space-4);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
/* --- Auth --- */
.auth-container {
max-width: 400px;
margin: var(--space-12) auto;
}
.auth-container .page-title {
margin-bottom: var(--space-6);
}
.auth-form {
margin-bottom: var(--space-4);
}
.auth-footer {
text-align: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-4);
}
.passkey-btn {
display: none;
margin-top: var(--space-3);
}
/* --- Repo header --- */
.repo-header {
margin-bottom: var(--space-2);
}
.repo-title-row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.repo-title-row .page-title {
margin-bottom: 0;
}
.repo-header-title {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.repo-description {
color: var(--color-text-muted);
margin-top: var(--space-2);
}
.repo-header-meta {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-3);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.icon {
font-style: normal;
}
/* --- Branch selector --- */
.branch-selector {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.branch-selector-icon {
color: var(--color-text-muted);
font-size: var(--text-sm);
user-select: none;
}
.branch-select {
padding: var(--space-1) var(--space-2);
padding-right: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
font-size: var(--text-sm);
font-family: var(--font-mono);
color: var(--color-text);
cursor: pointer;
appearance: auto;
max-width: 180px;
}
.branch-select:hover {
border-color: var(--color-text-muted);
}
.branch-select:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
}
/* toolbar row above file tree */
.tree-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
/* commits-header flex row */
.commits-header {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.commits-header .section-title {
margin-bottom: 0;
}
/* --- Repo nav bar (tabs + private badge) --- */
.repo-nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-6);
gap: var(--space-4);
}
/* --- Repo tabs --- */
.repo-tabs {
display: flex;
gap: 0;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
}
.repo-tab {
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
text-decoration: none;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.1s;
}
.repo-tab:hover {
color: var(--color-text);
}
.repo-tab.active {
color: var(--color-text);
font-weight: 600;
border-bottom-color: var(--color-accent);
}
/* --- Repo list --- */
.repo-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.repo-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg);
gap: var(--space-4);
transition: border-color 0.15s;
}
.repo-card:hover {
border-color: var(--color-accent);
}
.repo-card-main {
flex: 1;
min-width: 0;
}
.repo-card-title {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.repo-name {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-link);
text-decoration: none;
word-break: break-all;
}
.repo-name:hover {
text-decoration: underline;
}
.repo-card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
}
.repo-date {
color: var(--color-text-muted);
}
/* --- Badges --- */
.badge {
display: inline-flex;
align-items: center;
padding: 1px var(--space-2);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
border: 1px solid;
text-decoration: none;
}
.badge-private {
color: var(--color-warning);
border-color: var(--color-warning);
background: var(--color-warning-bg);
}
.badge-pinned {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-bg);
}
.issue-badge,
.patch-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
text-transform: capitalize;
}
.issue-badge.open,
.patch-badge.open {
background: var(--color-success-bg);
color: var(--color-success);
}
.issue-badge.closed,
.patch-badge.closed {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.issue-badge.completed {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.patch-badge.merged {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.sig-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
}
.sig-badge.verified {
background: var(--color-success-bg);
color: var(--color-success);
}
.sig-badge.unverified {
background: var(--color-danger-bg);
color: var(--color-danger);
}
/* --- Empty state --- */
.empty-state {
text-align: center;
padding: var(--space-16) var(--space-4);
color: var(--color-text-muted);
}
.empty-state p {
margin-bottom: var(--space-4);
font-size: var(--text-lg);
}
/* --- Breadcrumb --- */
.breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.breadcrumb a {
color: var(--color-link);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb-sep {
color: var(--color-text-muted);
}
.breadcrumb-current {
color: var(--color-text-muted);
}
/* --- Issue / Patch list --- */
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
gap: var(--space-4);
}
.list-header-tabs {
display: flex;
gap: var(--space-2);
}
.list-tab {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
text-decoration: none;
color: var(--color-text-muted);
}
.list-tab:hover {
background: var(--color-bg-inset);
color: var(--color-text);
}
.list-tab.active {
background: var(--color-bg-inset);
color: var(--color-text);
font-weight: 600;
}
.issue-list {
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.issue-item {
padding: var(--space-4) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
}
.issue-item:last-child {
border-bottom: none;
}
.issue-main {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
flex-wrap: wrap;
}
.issue-title {
font-weight: 500;
text-decoration: none;
color: var(--color-text);
flex: 1;
}
.issue-title:hover {
color: var(--color-link);
}
.issue-meta {
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
gap: var(--space-3);
}
.issue-status-dot,
.patch-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.issue-status-dot.open,
.patch-status-dot.open {
background: var(--color-success);
}
.issue-status-dot.closed,
.patch-status-dot.closed {
background: var(--color-danger);
}
.issue-status-dot.completed {
background: var(--color-merged);
}
.patch-status-dot.merged {
background: var(--color-merged);
}
/* --- Issue detail / Timeline --- */
.issue-detail-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.issue-number {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--color-text-muted);
white-space: nowrap;
}
.issue-detail-title {
font-size: var(--text-2xl);
font-weight: 600;
overflow-wrap: anywhere;
}
.issue-detail-meta-actions {
margin-left: auto;
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.patch-author-meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
margin-top: calc(-1 * var(--space-4));
margin-bottom: var(--space-6);
}
.patch-author-label {
color: var(--color-text-muted);
}
.patch-author-identity {
font-family: monospace;
}
.timeline-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.timeline-author {
background: var(--color-bg-subtle);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
border-bottom: 1px solid var(--color-border);
overflow-wrap: anywhere;
}
.timeline-author time {
color: var(--color-text-muted);
font-size: var(--text-xs);
width: max-content;
}
.timeline-author-right {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.edited-indicator {
color: var(--color-text-muted);
font-size: var(--text-xs);
cursor: default;
}
/* --- Avatars --- */
.avatar {
display: inline-flex;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
vertical-align: middle;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
color: transparent;
}
.avatar-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: var(--color-bg-inset);
color: var(--color-text-muted);
font-weight: 600;
flex-shrink: 0;
vertical-align: middle;
}
.avatar-settings {
display: flex;
align-items: flex-start;
gap: var(--space-5);
}
.avatar-actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.nav-user {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.timeline-body {
padding: var(--space-4);
}
.timeline-item-new {
padding: var(--space-4);
}
.timeline-item-new .section-title {
font-size: var(--text-base);
border: none;
padding-bottom: 0;
}
/* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */
.timeline-author-right .inline-edit-details {
border: none;
}
.inline-edit-details .when-open {
display: none;
}
.inline-edit-details[open] .when-closed {
display: none;
}
.inline-edit-details[open] .when-open {
display: inline;
}
.inline-edit-form-area {
display: none;
border-top: 1px solid var(--color-border-muted);
}
.timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area {
display: block;
}
.timeline-item:has(.inline-edit-details[open]) .timeline-body {
display: none;
}
/* Title edit in issue/patch header */
.issue-detail-header {
align-items: flex-start;
}
.title-with-edit {
flex: 1;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.title-edit-details {
border: none;
align-self: flex-start;
}
.title-with-edit:has(.title-edit-details[open]) .issue-detail-title {
display: none;
}
.title-with-edit:has(.title-edit-details[open]) > .title-edit-details {
display: none;
}
.title-edit-details > summary {
list-style: none;
}
.title-edit-form-area {
display: none;
}
.title-with-edit:has(.title-edit-details[open]) .title-edit-form-area {
display: flex;
flex: 1;
min-width: 0;
}
.title-edit-form {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.title-edit-form .form-input {
min-width: 0;
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.title-edit-form .form-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.title-edit-actions {
display: flex;
gap: var(--space-2);
}
.inline-edit-form {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* --- Reactions --- */
.reaction-bar {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-top: 1px solid var(--color-border-muted);
}
.reaction-form {
display: inline;
}
.reaction-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
transition: background 0.1s;
}
.reaction-btn:hover {
background: var(--color-bg-inset);
}
.reaction-btn.reacted {
background: var(--color-accent-bg);
border-color: var(--color-accent);
}
.reaction-picker {
position: relative;
}
.reaction-add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-full);
background: transparent;
cursor: pointer;
font-size: var(--text-base);
color: var(--color-text-muted);
list-style: none;
}
.reaction-picker-dropdown {
position: absolute;
bottom: calc(100% + var(--space-2));
left: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-2);
display: flex;
gap: var(--space-1);
z-index: 10;
}
.reaction-picker-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius-sm);
}
.reaction-picker-btn:hover {
background: var(--color-bg-inset);
}
/* --- Patch subview tabs --- */
.patch-tab-nav {
display: flex;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-6);
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
padding: 0 var(--space-1);
margin-left: var(--space-1);
font-size: var(--text-xs);
font-weight: 500;
background: var(--color-bg-inset);
border-radius: 999px;
color: var(--color-text-muted);
}
/* --- Patch apply status --- */
.apply-status {
margin: var(--space-4) 0;
}
.apply-result {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
flex-direction: column;
}
.apply-result > div {
display: flex;
gap: var(--space-2);
align-items: center;
}
.apply-clean {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.apply-conflict {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
}
.apply-checking,
.apply-unknown {
color: var(--color-text-muted);
font-style: italic;
font-size: var(--text-sm);
}
.apply-output {
font-family: var(--font-mono);
font-size: var(--text-xs);
white-space: pre-wrap;
margin-top: var(--space-2);
padding: var(--space-2);
background: rgba(0, 0, 0, 0.05);
border-radius: var(--radius-sm);
width: 100%;
}
.patch-actions {
display: flex;
gap: var(--space-3);
margin: var(--space-4) 0;
flex-wrap: wrap;
}
.patch-card-title-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
justify-content: space-between;
margin-bottom: var(--space-3);
}
.patch-card-title-row .commit-card-subject {
margin-bottom: 0;
}
.patch-card-description {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-muted);
}
/* --- Repo home layout --- */
.repo-home-layout {
display: grid;
gap: var(--space-8);
}
@media (min-width: 768px) {
.repo-home-layout {
grid-template-columns: 1fr 1fr;
}
}
.readme-section {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.readme-header {
background: var(--color-bg-subtle);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: 500;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
}
/* --- Markdown body --- */
.markdown-body {
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: var(--space-6);
margin-bottom: var(--space-3);
}
.markdown-body p {
margin-bottom: var(--space-4);
}
.markdown-body a {
color: var(--color-link);
}
.markdown-body code {
background: var(--color-bg-inset);
padding: 0.1em 0.3em;
border-radius: var(--radius-sm);
font-size: 0.9em;
}
.markdown-body pre {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
overflow: auto;
margin-bottom: var(--space-4);
}
.markdown-body pre code {
background: none;
padding: 0;
}
.markdown-body ul,
.markdown-body ol {
padding-left: var(--space-6);
margin-bottom: var(--space-4);
}
.markdown-body ul {
list-style: disc;
}
.markdown-body ol {
list-style: decimal;
}
.markdown-body li {
margin-bottom: var(--space-1);
}
.markdown-body blockquote {
border-left: 3px solid var(--color-border);
padding-left: var(--space-4);
color: var(--color-text-muted);
margin: var(--space-4) 0;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
}
.markdown-body th,
.markdown-body td {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
}
.markdown-body th {
background: var(--color-bg-subtle);
font-weight: 600;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-6) 0;
}
.markdown-body img {
max-width: 100%;
border-radius: var(--radius-md);
}
.markdown-body details {
margin-bottom: var(--space-4);
}
.markdown-body summary {
cursor: pointer;
font-weight: 500;
}
.readme-section .markdown-body,
.file-blob-body .markdown-body {
padding: var(--space-4);
}
/* --- Code setup block --- */
.code-setup {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
font-size: var(--text-sm);
margin-top: var(--space-4);
overflow: auto;
text-align: left;
}
.code-plain {
background: var(--color-bg-subtle);
padding: var(--space-4);
overflow: auto;
font-size: var(--text-sm);
line-height: 1.6;
}
/* --- Search row (search form + sort select) --- */
.search-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.search-form {
flex: 1;
}
.repo-sort-select {
padding: var(--space-1) var(--space-2);
padding-right: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
appearance: auto;
}
.repo-sort-select:hover {
border-color: var(--color-text-muted);
}
.repo-sort-select:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
}
.search-input-wrap {
display: flex;
gap: var(--space-2);
}
.search-input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.search-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
/* --- Issue close bar --- */
.issue-close-bar {
display: flex;
justify-content: flex-end;
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
}
/* --- Auth divider --- */
.auth-divider {
display: flex;
align-items: center;
gap: var(--space-3);
margin: var(--space-4) 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.auth-divider::before,
.auth-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--color-border);
}
/* --- Passkey UI in settings --- */
.passkey-actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
align-items: flex-start;
margin-top: var(--space-3);
}
.passkey-status {
font-size: var(--text-sm);
}
/* --- Danger zone --- */
.danger-zone {
margin-top: var(--space-8);
}
.danger-title {
color: var(--color-danger);
border-bottom-color: var(--color-danger);
}
.danger-card {
border-color: var(--color-danger);
background: var(--color-danger-bg);
}
.danger-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.danger-item p {
margin: var(--space-1) 0 0;
}
.btn-danger {
background: var(--color-danger);
border-color: transparent;
color: #fff;
}
.btn-danger:hover {
background: color-mix(in srgb, var(--color-danger) 85%, #000);
}
/* --- Release list items --- */
.release-item-header {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.release-item-main {
flex: 1;
min-width: 0;
}
.release-item-title {
font-size: var(--text-lg);
font-weight: 700;
text-decoration: none;
color: var(--color-text);
display: block;
margin-bottom: var(--space-1);
}
.release-item-title:hover {
color: var(--color-link);
}
.release-item-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.release-item-date {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* --- Release notes preview in list --- */
.release-notes-section {
margin-top: var(--space-3);
}
.release-notes-label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2);
}
.notes-expand > summary {
list-style: none;
cursor: pointer;
outline: none;
}
.notes-preview {
max-height: calc(5 * 1.6em);
overflow: hidden;
position: relative;
white-space: pre-wrap;
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
word-break: break-word;
margin-bottom: var(--space-1);
}
.notes-preview::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2.5em;
background: linear-gradient(transparent, var(--color-bg));
pointer-events: none;
}
.notes-expand[open] .notes-preview {
display: none;
}
.notes-full {
padding-top: var(--space-3);
padding-bottom: var(--space-1);
}
.notes-expand[open] {
display: flex;
flex-direction: column;
}
.notes-expand[open] > summary {
order: 1;
}
.notes-expand[open] > .notes-full {
order: 0;
}
.notes-toggle-label {
display: inline-block;
font-size: var(--text-xs);
color: var(--color-link);
margin-top: var(--space-1);
}
.notes-toggle-label:hover {
text-decoration: underline;
}
.notes-toggle-label::before {
content: "Show more ↓";
}
.notes-expand[open] .notes-toggle-label::before {
content: "Show less ↑";
}
/* --- Release detail --- */
.release-detail {
padding: var(--space-6) 0;
}
.release-header {
margin-bottom: var(--space-6);
}
.release-notes {
margin-bottom: var(--space-6);
}
.release-assets {
margin-bottom: var(--space-6);
}
.release-assets-heading {
font-size: var(--text-base);
font-weight: 600;
margin-bottom: var(--space-3);
}
.asset-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.asset-item {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.asset-item:last-child {
border-bottom: none;
}
.asset-name {
flex: 1;
color: var(--color-link);
text-decoration: none;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.asset-name:hover {
text-decoration: underline;
}
.asset-name-pending {
color: var(--color-text-muted);
cursor: default;
}
.asset-item-pending {
opacity: 0.6;
}
.asset-size,
.asset-meta {
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: nowrap;
}
.release-back {
margin-top: var(--space-4);
display: flex;
gap: var(--space-3);
align-items: center;
justify-content: space-between;
}
/* ============================================================
Pagination
============================================================ */
.commit-verify-hint {
margin-bottom: var(--space-4);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.commit-verify-hint code {
word-break: break-all;
}
.commit-cursor-nav {
display: flex;
justify-content: space-between;
border-top: 1px solid var(--color-border);
margin-top: var(--space-2);
padding: var(--space-4) 0 var(--space-2);
}
.commit-cursor-prev {
flex: 1;
}
.commit-cursor-next {
flex: 1;
text-align: right;
}
.pagination {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
border-top: 1px solid var(--color-border);
margin-top: var(--space-2);
padding: var(--space-4) 0 var(--space-2);
}
.pagination-prev {
justify-self: start;
}
.pagination-next {
justify-self: end;
}
.pagination-info {
justify-self: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.pagination-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
color: var(--color-text);
background: var(--color-bg-subtle);
transition:
background 0.1s,
border-color 0.1s;
white-space: nowrap;
}
.pagination-btn:hover {
background: var(--color-bg-inset);
border-color: var(--color-border);
color: var(--color-text);
text-decoration: none;
}
/* Settings */
.theme-options {
display: flex;
gap: var(--space-4);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.theme-option {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--text-sm);
}
.settings-form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.passkey-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.passkey-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.passkey-item:last-child {
border-bottom: none;
}
.passkey-date {
flex: 1;
color: var(--color-text-muted);
}
.queue-bulk-actions {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.queue-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 32rem;
overflow-y: auto;
}
.queue-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.queue-item-meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.queue-item-header {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.queue-item-date {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.queue-item-answer {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: pre-wrap;
word-break: break-word;
}
.queue-item-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.ssh-key-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.ssh-key-name {
font-weight: 500;
}
.ssh-key-fingerprint {
font-family: var(--font-mono);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-textarea {
resize: vertical;
min-height: 4.5rem;
font-family: var(--font-mono);
font-size: var(--text-sm);
}
/* Clone popup */
.clone-popup-details {
position: relative;
display: inline-block;
}
.clone-popup-details > summary {
list-style: none;
}
.timeline-author-right .inline-edit-details > summary {
list-style: none;
}
.clone-popup {
position: absolute;
right: 1rem;
top: calc(100% + var(--space-2));
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3);
min-width: 22rem;
z-index: 100;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.clone-url-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.clone-url-label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
min-width: 2.75rem;
}
.clone-url-input {
flex: 1;
font-family: var(--font-mono);
font-size: var(--text-sm);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-muted);
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-2);
color: var(--color-text);
cursor: text;
min-width: 0;
}
/* User list */
.user-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-6);
}
.user-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.user-item:last-child {
border-bottom: none;
}
.user-name {
font-weight: 500;
flex: 1;
}
.user-date {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
/* Labels */
.label-badge {
display: inline-block;
padding: 0 var(--space-2);
border-radius: 2em;
font-size: var(--text-xs);
font-weight: 500;
line-height: 1.6;
word-break: break-all;
}
.issue-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.issue-labels-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0 var(--space-3);
}
.label-badge-wrap {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.label-remove-form {
display: inline;
}
.label-remove-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 2px;
font-size: var(--text-sm);
line-height: 1;
color: var(--color-text-muted);
opacity: 0.7;
}
.label-remove-btn:hover {
opacity: 1;
}
.label-add-inline-form {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.label-select {
font-size: var(--text-sm);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
height: 2rem;
}
.label-checkbox-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.label-checkbox-item {
display: inline-flex;
align-items: center;
gap: var(--space-1);
cursor: pointer;
}
.label-settings-list {
display: flex;
flex-direction: column;
margin-bottom: var(--space-4);
max-height: 24rem;
overflow-y: auto;
}
.label-settings-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.label-settings-item:last-child {
border-bottom: none;
}
.label-settings-swatch {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(0, 0, 0, 0.15);
}
.label-settings-name {
flex: 1;
font-weight: 500;
}
.label-add-form {
display: flex;
align-items: center;
gap: var(--space-2);
}
.label-name-input {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
height: 2rem;
box-sizing: border-box;
}
.label-name-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.label-color-swatch-label {
flex-shrink: 0;
cursor: pointer;
display: block;
line-height: 0;
}
.label-color-input {
display: block;
width: 2rem;
height: 2rem;
cursor: pointer;
appearance: none;
box-sizing: border-box;
clip-path: circle(50%);
border: none;
}
/* Label filter (details/summary popup) */
.list-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.label-filter {
position: relative;
}
.label-filter-popup {
position: absolute;
right: 0;
top: calc(100% + var(--space-1));
z-index: 10;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3);
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.label-filter-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-3);
max-height: 16rem;
overflow-y: auto;
}
.label-filter-item {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--text-sm);
}
.label-filter-actions {
display: flex;
gap: var(--space-2);
}
}
Msrc/styles/main.css
@@ -1,2905 +1,7 @@
/* View Transitions */
@view-transition {
navigation: auto;
}
/* Declare layer order explicitly */
@layer reset, tokens, layout, components, utilities;
.site-nav {
view-transition-name: site-nav;
}
.repo-header {
view-transition-name: repo-header;
}
.breadcrumb {
view-transition-name: breadcrumb;
}
/* ============================================================
@layer reset
============================================================ */
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
min-height: 100dvh;
min-width: 500px;
}
img,
video {
max-width: 100%;
height: auto;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
}
ul,
ol {
list-style: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.2;
}
pre,
code {
font-family: var(--font-mono);
}
}
/* ============================================================
@layer tokens
============================================================ */
@layer tokens {
:root {
/* Colors — light */
--color-bg: #ffffff;
--color-bg-subtle: #f6f8fa;
--color-bg-inset: #eef0f2;
--color-border: #d0d7de;
--color-border-muted: #e4e7eb;
--color-text: #1f2328;
--color-text-muted: #656d76;
--color-link: #0969da;
--color-link-hover: #0550ae;
--color-accent: #fd7e14;
--color-accent-bg: #fff3cd;
--color-success: #1a7f37;
--color-success-bg: #dafbe1;
--color-danger: #cf222e;
--color-danger-bg: #ffebe9;
--color-warning: #9a6700;
--color-warning-bg: #fff8c5;
--color-merged: #8250df;
--color-merged-bg: #fbefff;
/* Typography */
--font-sans:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono:
ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
/* Border radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 0 rgba(27, 31, 36, 0.04);
--shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15);
--shadow-lg: 0 8px 24px rgba(140, 149, 159, 0.2);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0d1117;
--color-bg-subtle: #161b22;
--color-bg-inset: #1c2128;
--color-border: #30363d;
--color-border-muted: #21262d;
--color-text: #e6edf3;
--color-text-muted: #7d8590;
--color-link: #58a6ff;
--color-link-hover: #79c0ff;
--color-accent: #f0883e;
--color-accent-bg: #3d2f00;
--color-success: #3fb950;
--color-success-bg: #0d2b19;
--color-danger: #f85149;
--color-danger-bg: #2d1419;
--color-warning: #d29922;
--color-warning-bg: #2c2000;
--color-merged: #bc8cff;
--color-merged-bg: #1e0b3e;
}
}
/* Explicit theme overrides — applied via data-theme attribute set by cookie */
:root[data-theme="dark"] {
--color-bg: #0d1117;
--color-bg-subtle: #161b22;
--color-bg-inset: #1c2128;
--color-border: #30363d;
--color-border-muted: #21262d;
--color-text: #e6edf3;
--color-text-muted: #7d8590;
--color-link: #58a6ff;
--color-link-hover: #79c0ff;
--color-accent: #f0883e;
--color-accent-bg: #3d2f00;
--color-success: #3fb950;
--color-success-bg: #0d2b19;
--color-danger: #f85149;
--color-danger-bg: #2d1419;
--color-warning: #d29922;
--color-warning-bg: #2c2000;
--color-merged: #bc8cff;
--color-merged-bg: #1e0b3e;
}
:root[data-theme="light"] {
--color-bg: #ffffff;
--color-bg-subtle: #f6f8fa;
--color-bg-inset: #eef0f2;
--color-border: #d0d7de;
--color-border-muted: #e4e7eb;
--color-text: #1f2328;
--color-text-muted: #656d76;
--color-link: #0969da;
--color-link-hover: #0550ae;
--color-accent: #fd7e14;
--color-accent-bg: #fff3cd;
--color-success: #1a7f37;
--color-success-bg: #dafbe1;
--color-danger: #cf222e;
--color-danger-bg: #ffebe9;
--color-warning: #9a6700;
--color-warning-bg: #fff8c5;
--color-merged: #8250df;
--color-merged-bg: #fbefff;
}
}
/* ============================================================
@layer layout
============================================================ */
@layer layout {
body {
font-family: var(--font-sans);
font-size: var(--text-base);
color: var(--color-text);
background: var(--color-bg);
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
}
.site-header {
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
}
.site-nav {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
gap: var(--space-4);
}
.site-logo {
display: flex;
align-items: center;
gap: var(--space-2);
text-decoration: none;
font-weight: 700;
font-size: var(--text-lg);
color: var(--color-text);
}
.logo-icon {
width: 1.6em;
height: 1.6em;
display: block;
flex-shrink: 0;
}
.nav-links {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-3);
}
.nav-user {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.site-main {
padding: var(--space-8) var(--space-4);
min-width: 0;
}
.site-footer {
text-align: center;
padding: var(--space-6) var(--space-4);
font-size: var(--text-sm);
color: var(--color-text-muted);
border-top: 1px solid var(--color-border);
}
.container {
max-width: 1000px;
margin: 0 auto;
overflow-x: clip;
}
.container-narrow {
max-width: 680px;
}
.inline-form {
display: inline;
}
/* --- Two-step delete confirmation --- */
.confirm-details {
position: relative;
display: inline-block;
}
.confirm-details > summary {
list-style: none;
}
.confirm-details > summary::-webkit-details-marker {
display: none;
}
.confirm-popup {
position: absolute;
right: 0;
top: calc(100% + var(--space-1));
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
white-space: nowrap;
z-index: 100;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
}
/* ============================================================
@layer components
============================================================ */
@layer components {
/* --- Buttons --- */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
cursor: pointer;
background: var(--color-bg-subtle);
color: var(--color-text);
transition:
background 0.1s,
border-color 0.1s;
white-space: nowrap;
}
.btn:hover {
background: var(--color-bg-inset);
border-color: var(--color-border);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
border-color: transparent;
color: #fff;
}
.btn-primary:hover {
background: color-mix(in srgb, var(--color-accent) 82%, #000);
}
.btn-secondary {
background: var(--color-bg-subtle);
border-color: var(--color-border);
}
.btn-sm {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
}
.btn-block {
width: 100%;
justify-content: center;
}
/* --- Forms --- */
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.form-group label,
.form-group .form-group-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text);
}
.form-group input:not([type="file"]):not([type="checkbox"]),
.form-group textarea,
.form-group select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.form-group input:not([type="file"]):not([type="checkbox"]):focus,
.form-group textarea:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.form-group input[type="file"] {
font-size: var(--text-sm);
color: var(--color-text);
padding: 0;
border: none;
background: none;
}
.form-group textarea {
resize: vertical;
min-height: 120px;
}
.form-success {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.form-hint {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin: 0;
}
.form-error {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.form-card {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
margin-top: var(--space-4);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
/* --- Page header --- */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
}
.page-title {
font-size: var(--text-2xl);
color: var(--color-text);
margin-bottom: var(--space-4);
}
.page-title a {
text-decoration: none;
color: var(--color-link);
}
.page-title a:hover {
text-decoration: underline;
}
.section-title {
font-size: var(--text-lg);
margin-bottom: var(--space-4);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
/* --- Auth --- */
.auth-container {
max-width: 400px;
margin: var(--space-12) auto;
}
.auth-container .page-title {
margin-bottom: var(--space-6);
}
.auth-form {
margin-bottom: var(--space-4);
}
.auth-footer {
text-align: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-4);
}
.passkey-btn {
display: none;
margin-top: var(--space-3);
}
/* --- Repo header --- */
.repo-header {
margin-bottom: var(--space-2);
}
.repo-title-row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.repo-title-row .page-title {
margin-bottom: 0;
}
.repo-header-title {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.repo-description {
color: var(--color-text-muted);
margin-top: var(--space-2);
}
.repo-header-meta {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-3);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.icon {
font-style: normal;
}
/* --- Branch selector --- */
.branch-selector {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.branch-selector-icon {
color: var(--color-text-muted);
font-size: var(--text-sm);
user-select: none;
}
.branch-select {
padding: var(--space-1) var(--space-2);
padding-right: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
font-size: var(--text-sm);
font-family: var(--font-mono);
color: var(--color-text);
cursor: pointer;
appearance: auto;
max-width: 180px;
}
.branch-select:hover {
border-color: var(--color-text-muted);
}
.branch-select:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
}
/* toolbar row above file tree */
.tree-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
/* commits-header flex row */
.commits-header {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.commits-header .section-title {
margin-bottom: 0;
}
/* --- Repo nav bar (tabs + private badge) --- */
.repo-nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-6);
gap: var(--space-4);
}
/* --- Repo tabs --- */
.repo-tabs {
display: flex;
gap: 0;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
}
.repo-tab {
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
text-decoration: none;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.1s;
}
.repo-tab:hover {
color: var(--color-text);
}
.repo-tab.active {
color: var(--color-text);
font-weight: 600;
border-bottom-color: var(--color-accent);
}
/* Align active indicator with nav-bar border */
.repo-tab {
margin-bottom: -1px;
}
/* --- Repo list --- */
.repo-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.repo-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg);
gap: var(--space-4);
transition: border-color 0.15s;
}
.repo-card:hover {
border-color: var(--color-accent);
}
.repo-card-main {
flex: 1;
min-width: 0;
}
.repo-card-title {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.repo-name {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-link);
text-decoration: none;
word-break: break-all;
}
.repo-name:hover {
text-decoration: underline;
}
.repo-card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
}
.repo-date {
color: var(--color-text-muted);
}
/* --- Badges --- */
.badge {
display: inline-flex;
align-items: center;
padding: 1px var(--space-2);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
border: 1px solid;
text-decoration: none;
}
.badge-private {
color: var(--color-warning);
border-color: var(--color-warning);
background: var(--color-warning-bg);
}
.badge-pinned {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-bg);
}
.issue-badge,
.patch-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
text-transform: capitalize;
}
.issue-badge.open,
.patch-badge.open {
background: var(--color-success-bg);
color: var(--color-success);
}
.issue-badge.closed,
.patch-badge.closed {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.issue-badge.completed {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.patch-badge.merged {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.sig-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
}
.sig-badge.verified {
background: var(--color-success-bg);
color: var(--color-success);
}
.sig-badge.unverified {
background: var(--color-danger-bg);
color: var(--color-danger);
}
/* --- Empty state --- */
.empty-state {
text-align: center;
padding: var(--space-16) var(--space-4);
color: var(--color-text-muted);
}
.empty-state p {
margin-bottom: var(--space-4);
font-size: var(--text-lg);
}
/* --- Breadcrumb --- */
.breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.breadcrumb a {
color: var(--color-link);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb-sep {
color: var(--color-text-muted);
}
.breadcrumb-current {
color: var(--color-text-muted);
}
/* --- File tree --- */
.file-tree {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
border-collapse: collapse;
overflow: hidden;
margin-bottom: var(--space-6);
}
.file-tree-row {
border-bottom: 1px solid var(--color-border-muted);
}
.file-tree-row:last-child {
border-bottom: none;
}
.file-tree-row:hover {
background: var(--color-bg-subtle);
}
.file-icon,
.file-name,
.file-size {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
vertical-align: middle;
}
.file-icon {
width: 36px;
padding-right: var(--space-1);
text-align: center;
line-height: 0;
}
.file-name {
width: 100%;
}
.file-name a {
color: var(--color-link);
text-decoration: none;
}
.file-name a:hover {
text-decoration: underline;
}
.file-name a.file-name-dir {
font-weight: 500;
}
.file-size {
text-align: right;
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: nowrap;
padding-right: var(--space-4);
}
.file-icon-dir {
color: var(--color-link);
}
.file-icon-file {
color: var(--color-text-muted);
}
.tree-icon {
display: inline-block;
vertical-align: text-bottom;
}
.file-tree-row-up .file-name a {
color: var(--color-text-muted);
}
.file-tree-row-up .file-name a:hover {
color: var(--color-link);
}
/* --- File blob --- */
.file-blob-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
margin-top: var(--space-4);
}
.file-blob-name {
font-size: var(--text-sm);
font-weight: 500;
}
.file-blob-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.file-blob-body {
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
overflow: auto;
}
.file-edit-textarea {
display: block;
width: 100%;
box-sizing: border-box;
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.6;
padding: var(--space-4);
background: var(--color-bg);
color: var(--color-text);
border: none;
resize: vertical;
min-height: 400px;
}
.file-edit-textarea:focus {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
/* Blob line-number table */
.blob-table {
min-width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.6;
background-color: var(--shiki-light-bg, #fff);
color: var(--shiki-light, inherit);
}
.blob-ln {
width: 1%;
min-width: 48px;
text-align: right;
padding: 0 var(--space-3);
color: var(--color-text-muted);
border-right: 1px solid var(--color-border-muted);
user-select: none;
white-space: nowrap;
vertical-align: top;
}
.blob-ln a {
color: inherit;
text-decoration: none;
}
.blob-ln a:hover {
color: var(--color-link);
}
.blob-code {
padding: 0 var(--space-4);
white-space: pre;
overflow: visible;
vertical-align: top;
}
.blob-table tr:target {
background: color-mix(in srgb, #ddf4ff 60%, transparent);
}
.blob-table tr:target .blob-ln {
background: color-mix(in srgb, #ddf4ff 80%, transparent);
}
.file-download-notice {
padding: var(--space-8);
text-align: center;
color: var(--color-text-muted);
}
.file-download-notice p {
margin-bottom: var(--space-4);
}
.file-media {
padding: var(--space-4);
display: flex;
justify-content: center;
background: var(--color-canvas-subtle);
}
.file-media-img {
max-width: 100%;
height: auto;
display: block;
}
.file-media-audio {
width: 100%;
max-width: 600px;
}
.file-media-video {
max-width: 100%;
max-height: 80vh;
}
/* --- Commit list --- */
.commit-list {
display: flex;
flex-direction: column;
}
.commit-item {
display: flex;
flex-direction: column;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border-muted);
gap: var(--space-2);
}
.commit-item:last-child {
border-bottom: none;
}
.commit-subject {
color: var(--color-text);
text-decoration: none;
font-size: var(--text-sm);
font-weight: 500;
}
.commit-subject:hover {
color: var(--color-link);
}
.commit-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.commit-meta-right {
display: flex;
align-items: center;
gap: var(--space-3);
white-space: nowrap;
}
.commit-hash {
font-family: var(--font-mono);
color: var(--color-link);
text-decoration: none;
background: var(--color-bg-inset);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
}
.commit-hash:hover {
text-decoration: underline;
}
.mono {
font-family: var(--font-mono);
}
/* --- Commit detail viewer --- */
.commit-page-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.commit-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.commit-card-subject {
font-size: var(--text-xl);
font-weight: 600;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
line-height: 1.3;
}
.commit-card-subject-empty {
color: var(--color-text-muted);
font-style: italic;
font-weight: 400;
font-size: var(--text-sm);
}
.commit-card-body {
padding: var(--space-4) var(--space-6);
font-family: inherit;
font-size: var(--text-sm);
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.commit-card-meta {
padding: var(--space-4) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-2);
background: var(--color-bg-subtle);
}
.commit-card-meta-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
font-size: var(--text-sm);
flex-wrap: wrap;
}
.commit-meta-label {
font-weight: 600;
color: var(--color-text-muted);
min-width: 5rem;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.commit-sha-full {
font-size: var(--text-xs);
background: var(--color-bg-inset);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
word-break: break-all;
}
.commit-stats-bar {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}
/* --- Commit layout (sidebar + diffs) --- */
.commit-layout {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-5);
align-items: start;
}
.commit-diffs {
min-width: 0;
}
@media (min-width: 900px) {
.commit-layout {
grid-template-columns: 220px 1fr;
}
.commit-layout:has(.file-nav-details:not([open])) {
grid-template-columns: auto 1fr;
}
}
/* --- File nav sidebar --- */
.commit-file-nav {
position: sticky;
top: calc(56px + var(--space-4));
max-height: calc(100vh - 140px);
overflow-y: auto;
}
.file-nav-details {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-subtle);
overflow: hidden;
}
.file-nav-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
list-style: none;
user-select: none;
border-bottom: 1px solid var(--color-border);
}
.file-nav-toggle::-webkit-details-marker {
display: none;
}
.file-nav-details:not([open]) .file-nav-toggle {
border-bottom: none;
}
.file-nav-toggle-icon {
font-size: 1em;
transition: transform 0.15s;
}
.file-nav-details:not([open]) .file-nav-toggle-icon {
transform: rotate(-90deg);
}
.file-nav-list {
padding: var(--space-1) 0;
list-style: none;
}
.file-nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
text-decoration: none;
color: var(--color-text);
border-radius: 0;
overflow: hidden;
}
.file-nav-item:hover {
background: var(--color-bg-inset);
}
.file-nav-status {
flex-shrink: 0;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 9px;
font-weight: 700;
font-family: var(--font-mono);
}
.file-status-added {
background: var(--color-success-bg);
color: var(--color-success);
}
.file-status-deleted {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.file-status-modified {
background: var(--color-accent-bg);
color: var(--color-accent);
}
.file-status-renamed {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.file-status-copied {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.file-nav-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
}
.file-nav-stat {
flex-shrink: 0;
display: flex;
gap: var(--space-1);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.nav-add {
color: var(--color-success);
}
.nav-del {
color: var(--color-danger);
}
.nav-binary {
color: var(--color-muted);
font-size: var(--text-xs);
}
.file-nav-dir > details > .file-nav-list {
padding-left: var(--space-4);
}
.file-nav-dir-toggle {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-family: var(--font-mono);
color: var(--color-text-muted);
cursor: pointer;
list-style: none;
user-select: none;
}
.file-nav-dir-toggle::-webkit-details-marker {
display: none;
}
.file-nav-dir > details:not([open]) .file-nav-toggle-icon {
transform: rotate(-90deg);
}
/* --- Diff file cards --- */
.diff-file {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.diff-file-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
cursor: pointer;
list-style: none;
user-select: none;
flex-wrap: wrap;
}
.diff-file-header::-webkit-details-marker {
display: none;
}
.diff-file-header-left {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
flex: 1;
}
.diff-file-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.diff-file-toggle-icon {
font-size: 0.7em;
flex-shrink: 0;
transition: transform 0.15s;
}
.diff-file:not([open]) .diff-file-toggle-icon {
transform: rotate(-90deg);
}
.diff-status-badge {
flex-shrink: 0;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 700;
font-family: var(--font-mono);
}
.diff-status-added {
background: var(--color-success-bg);
color: var(--color-success);
}
.diff-status-deleted {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.diff-status-modified {
background: var(--color-accent-bg);
color: var(--color-accent);
}
.diff-status-renamed {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.diff-status-copied {
background: var(--color-merged-bg);
color: var(--color-merged);
}
.diff-file-path {
font-size: var(--text-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff-rename-arrow {
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.diff-stat-add {
color: var(--color-success);
font-size: var(--text-sm);
font-family: var(--font-mono);
font-weight: 600;
}
.diff-stat-del {
color: var(--color-danger);
font-size: var(--text-sm);
font-family: var(--font-mono);
font-weight: 600;
}
.diff-stat-binary {
color: var(--color-muted);
font-size: var(--text-sm);
font-family: var(--font-mono);
}
.btn-xs {
padding: 1px var(--space-2);
font-size: var(--text-xs);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
font-family: var(--font-mono);
display: inline-flex;
align-items: center;
transition: background 0.1s;
}
.btn-xs:hover {
background: var(--color-bg-inset);
}
/* --- Diff file body --- */
.diff-binary-notice {
padding: var(--space-4) var(--space-6);
font-size: var(--text-sm);
color: var(--color-text-muted);
font-style: italic;
}
.diff-hunk {
border-top: 1px solid var(--color-border-muted);
overflow-x: auto;
}
.diff-hunk:first-child {
border-top: none;
}
.diff-hunk-header {
padding: var(--space-1) var(--space-4);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: normal;
text-align: left;
background: color-mix(
in srgb,
var(--color-merged-bg) 50%,
var(--color-bg)
);
color: var(--color-merged);
border-bottom: 1px solid var(--color-border-muted);
white-space: pre;
}
/* --- Diff table --- */
.diff-table {
min-width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.5;
}
.diff-ln {
width: 44px;
min-width: 44px;
text-align: right;
padding: 0 var(--space-2);
color: var(--color-text-muted);
border-right: 1px solid var(--color-border-muted);
user-select: none;
vertical-align: top;
white-space: nowrap;
}
.diff-sign {
width: 18px;
min-width: 18px;
text-align: center;
padding: 0 var(--space-1);
user-select: none;
vertical-align: top;
}
.diff-code {
padding: 0 var(--space-3);
white-space: pre;
overflow: visible;
vertical-align: top;
word-break: normal;
}
/* Row type backgrounds */
.diff-row-add {
background: color-mix(
in srgb,
var(--color-success-bg) 70%,
transparent
);
}
.diff-row-del {
background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent);
}
.diff-row-add .diff-ln,
.diff-row-add .diff-sign {
background: color-mix(
in srgb,
var(--color-success-bg) 90%,
transparent
);
}
.diff-row-del .diff-ln,
.diff-row-del .diff-sign {
background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent);
}
.diff-row-add .diff-sign {
color: var(--color-success);
font-weight: 700;
}
.diff-row-del .diff-sign {
color: var(--color-danger);
font-weight: 700;
}
.diff-ctrl {
color: #f0f0f0;
background: #c0392b;
border-radius: 2px;
font-weight: bold;
padding: 0 1px;
}
/* Shiki dark-mode support in diff code cells */
@media (prefers-color-scheme: dark) {
.diff-code span[style] {
/* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
color: var(--shiki-dark) !important;
}
}
/* Shiki dark-mode support in file blob view */
@media (prefers-color-scheme: dark) {
.blob-table {
background-color: var(--shiki-dark-bg);
color: var(--shiki-dark);
}
.blob-table span[style] {
/* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
color: var(--shiki-dark) !important;
}
.blob-table tr:target {
background: color-mix(in srgb, #1c3a52 60%, transparent);
}
.blob-table tr:target .blob-ln {
background: color-mix(in srgb, #1c3a52 80%, transparent);
}
}
:root[data-theme="dark"] .blob-table {
background-color: var(--shiki-dark-bg);
color: var(--shiki-dark);
}
:root[data-theme="dark"] .blob-table span[style] {
/* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
color: var(--shiki-dark) !important;
}
/* --- Issue / Patch list --- */
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
gap: var(--space-4);
}
.list-header-tabs {
display: flex;
gap: var(--space-2);
}
.list-tab {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
text-decoration: none;
color: var(--color-text-muted);
}
.list-tab:hover {
background: var(--color-bg-inset);
color: var(--color-text);
}
.list-tab.active {
background: var(--color-bg-inset);
color: var(--color-text);
font-weight: 600;
}
.issue-list {
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.issue-item {
padding: var(--space-4) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
}
.issue-item:last-child {
border-bottom: none;
}
.issue-main {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
flex-wrap: wrap;
}
.issue-title {
font-weight: 500;
text-decoration: none;
color: var(--color-text);
flex: 1;
}
.issue-title:hover {
color: var(--color-link);
}
.issue-meta {
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
gap: var(--space-3);
}
.issue-status-dot,
.patch-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.issue-status-dot.open,
.patch-status-dot.open {
background: var(--color-success);
}
.issue-status-dot.closed,
.patch-status-dot.closed {
background: var(--color-danger);
}
.issue-status-dot.completed {
background: var(--color-merged);
}
.patch-status-dot.merged {
background: var(--color-merged);
}
/* --- Issue detail / Timeline --- */
.issue-detail-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.issue-number {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--color-text-muted);
white-space: nowrap;
}
.issue-detail-title {
font-size: var(--text-2xl);
font-weight: 600;
overflow-wrap: anywhere;
}
.issue-detail-meta-actions {
margin-left: auto;
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.patch-author-meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
margin-top: calc(-1 * var(--space-4));
margin-bottom: var(--space-6);
}
.patch-author-label {
color: var(--color-text-muted);
}
.patch-author-identity {
font-family: monospace;
}
.timeline-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.timeline-author {
background: var(--color-bg-subtle);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
border-bottom: 1px solid var(--color-border);
overflow-wrap: anywhere;
}
.timeline-author time {
color: var(--color-text-muted);
font-size: var(--text-xs);
width: max-content;
}
.timeline-author-right {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.edited-indicator {
color: var(--color-text-muted);
font-size: var(--text-xs);
cursor: default;
}
/* --- Avatars --- */
.avatar {
display: inline-flex;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
vertical-align: middle;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
color: transparent;
}
.avatar-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: var(--color-bg-inset);
color: var(--color-text-muted);
font-weight: 600;
flex-shrink: 0;
vertical-align: middle;
}
.avatar-settings {
display: flex;
align-items: flex-start;
gap: var(--space-5);
}
.avatar-actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.nav-user {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.timeline-body {
padding: var(--space-4);
}
.timeline-item-new {
padding: var(--space-4);
}
.timeline-item-new .section-title {
font-size: var(--text-base);
border: none;
padding-bottom: 0;
}
/* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */
.timeline-author-right .inline-edit-details {
border: none;
}
.timeline-author-right .inline-edit-details > summary {
list-style: none;
}
.timeline-author-right
.inline-edit-details
> summary::-webkit-details-marker {
display: none;
}
.inline-edit-details .when-open {
display: none;
}
.inline-edit-details[open] .when-closed {
display: none;
}
.inline-edit-details[open] .when-open {
display: inline;
}
.inline-edit-form-area {
display: none;
border-top: 1px solid var(--color-border-muted);
}
.timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area {
display: block;
}
.timeline-item:has(.inline-edit-details[open]) .timeline-body {
display: none;
}
/* Title edit in issue/patch header */
.issue-detail-header {
align-items: flex-start;
}
.title-with-edit {
flex: 1;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.title-edit-details {
border: none;
align-self: flex-start;
}
.title-with-edit:has(.title-edit-details[open]) .issue-detail-title {
display: none;
}
.title-with-edit:has(.title-edit-details[open]) > .title-edit-details {
display: none;
}
.title-edit-details > summary {
list-style: none;
}
.title-edit-details > summary::-webkit-details-marker {
display: none;
}
.title-edit-form-area {
display: none;
}
.title-with-edit:has(.title-edit-details[open]) .title-edit-form-area {
display: flex;
flex: 1;
min-width: 0;
}
.title-edit-form {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.title-edit-form .form-input {
min-width: 0;
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.title-edit-form .form-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.title-edit-actions {
display: flex;
gap: var(--space-2);
}
.inline-edit-form {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* --- Reactions --- */
.reaction-bar {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-top: 1px solid var(--color-border-muted);
}
.reaction-form {
display: inline;
}
.reaction-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
transition: background 0.1s;
}
.reaction-btn:hover {
background: var(--color-bg-inset);
}
.reaction-btn.reacted {
background: var(--color-accent-bg);
border-color: var(--color-accent);
}
.reaction-picker {
position: relative;
}
.reaction-add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-full);
background: transparent;
cursor: pointer;
font-size: var(--text-base);
color: var(--color-text-muted);
list-style: none;
}
.reaction-add-btn::-webkit-details-marker {
display: none;
}
.reaction-picker-dropdown {
position: absolute;
bottom: calc(100% + var(--space-2));
left: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-2);
display: flex;
gap: var(--space-1);
z-index: 10;
}
.reaction-picker-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius-sm);
}
.reaction-picker-btn:hover {
background: var(--color-bg-inset);
}
/* --- Patch subview tabs --- */
.patch-tab-nav {
display: flex;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-6);
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
padding: 0 var(--space-1);
margin-left: var(--space-1);
font-size: var(--text-xs);
font-weight: 500;
background: var(--color-bg-inset);
border-radius: 999px;
color: var(--color-text-muted);
}
/* --- Patch apply status --- */
.apply-status {
margin: var(--space-4) 0;
}
.apply-result {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
flex-direction: column;
}
.apply-result > div {
display: flex;
gap: var(--space-2);
align-items: center;
}
.apply-clean {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.apply-conflict {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
}
.apply-checking,
.apply-unknown {
color: var(--color-text-muted);
font-style: italic;
font-size: var(--text-sm);
}
.apply-output {
font-family: var(--font-mono);
font-size: var(--text-xs);
white-space: pre-wrap;
margin-top: var(--space-2);
padding: var(--space-2);
background: rgba(0, 0, 0, 0.05);
border-radius: var(--radius-sm);
width: 100%;
}
.patch-actions {
display: flex;
gap: var(--space-3);
margin: var(--space-4) 0;
flex-wrap: wrap;
}
.patch-card-title-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
justify-content: space-between;
margin-bottom: var(--space-3);
}
.patch-card-title-row .commit-card-subject {
margin-bottom: 0;
}
.patch-card-description {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-muted);
}
/* --- Repo home layout --- */
.repo-home-layout {
display: grid;
gap: var(--space-8);
}
@media (min-width: 768px) {
.repo-home-layout {
grid-template-columns: 1fr 1fr;
}
}
.readme-section {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.readme-header {
background: var(--color-bg-subtle);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: 500;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
}
/* --- Code setup block --- */
.code-setup {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
font-size: var(--text-sm);
margin-top: var(--space-4);
overflow: auto;
text-align: left;
}
.code-plain {
background: var(--color-bg-subtle);
padding: var(--space-4);
overflow: auto;
font-size: var(--text-sm);
line-height: 1.6;
}
/* --- Markdown body --- */
.markdown-body {
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: var(--space-6);
margin-bottom: var(--space-3);
}
.markdown-body p {
margin-bottom: var(--space-4);
}
.markdown-body a {
color: var(--color-link);
}
.markdown-body code {
background: var(--color-bg-inset);
padding: 0.1em 0.3em;
border-radius: var(--radius-sm);
font-size: 0.9em;
}
.markdown-body pre {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
overflow: auto;
margin-bottom: var(--space-4);
}
.markdown-body pre code {
background: none;
padding: 0;
}
.markdown-body ul,
.markdown-body ol {
padding-left: var(--space-6);
margin-bottom: var(--space-4);
}
.markdown-body ul {
list-style: disc;
}
.markdown-body ol {
list-style: decimal;
}
.markdown-body li {
margin-bottom: var(--space-1);
}
.markdown-body blockquote {
border-left: 3px solid var(--color-border);
padding-left: var(--space-4);
color: var(--color-text-muted);
margin: var(--space-4) 0;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
}
.markdown-body th,
.markdown-body td {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
}
.markdown-body th {
background: var(--color-bg-subtle);
font-weight: 600;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-6) 0;
}
.markdown-body img {
max-width: 100%;
border-radius: var(--radius-md);
}
.markdown-body details {
margin-bottom: var(--space-4);
}
.markdown-body summary {
cursor: pointer;
font-weight: 500;
}
.readme-section .markdown-body,
.file-blob-body .markdown-body {
padding: var(--space-4);
}
/* --- Search row (search form + sort select) --- */
.search-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.search-form {
flex: 1;
}
.repo-sort-select {
padding: var(--space-1) var(--space-2);
padding-right: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
appearance: auto;
}
.repo-sort-select:hover {
border-color: var(--color-text-muted);
}
.repo-sort-select:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
}
.search-input-wrap {
display: flex;
gap: var(--space-2);
}
.search-input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.search-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
/* --- Issue close bar --- */
.issue-close-bar {
display: flex;
justify-content: flex-end;
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
}
/* --- Auth divider --- */
.auth-divider {
display: flex;
align-items: center;
gap: var(--space-3);
margin: var(--space-4) 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.auth-divider::before,
.auth-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--color-border);
}
/* --- Passkey UI in settings --- */
.passkey-actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
align-items: flex-start;
margin-top: var(--space-3);
}
.passkey-status {
font-size: var(--text-sm);
}
/* --- Danger zone --- */
.danger-zone {
margin-top: var(--space-8);
}
.danger-title {
color: var(--color-danger);
border-bottom-color: var(--color-danger);
}
.danger-card {
border-color: var(--color-danger);
background: var(--color-danger-bg);
}
.danger-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.danger-item p {
margin: var(--space-1) 0 0;
}
.btn-danger {
background: var(--color-danger);
border-color: transparent;
color: #fff;
}
.btn-danger:hover {
background: color-mix(in srgb, var(--color-danger) 85%, #000);
}
/* --- Release list items --- */
.release-item-header {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.release-item-main {
flex: 1;
min-width: 0;
}
.release-item-title {
font-size: var(--text-lg);
font-weight: 700;
text-decoration: none;
color: var(--color-text);
display: block;
margin-bottom: var(--space-1);
}
.release-item-title:hover {
color: var(--color-link);
}
.release-item-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.release-item-date {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* --- Release notes preview in list --- */
.release-notes-section {
margin-top: var(--space-3);
}
.release-notes-label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2);
}
.notes-expand > summary {
list-style: none;
cursor: pointer;
outline: none;
}
.notes-expand > summary::-webkit-details-marker {
display: none;
}
.notes-preview {
max-height: calc(5 * 1.6em);
overflow: hidden;
position: relative;
white-space: pre-wrap;
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
word-break: break-word;
margin-bottom: var(--space-1);
}
.notes-preview::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2.5em;
background: linear-gradient(transparent, var(--color-bg));
pointer-events: none;
}
.notes-expand[open] .notes-preview {
display: none;
}
.notes-full {
padding-top: var(--space-3);
padding-bottom: var(--space-1);
}
.notes-expand[open] {
display: flex;
flex-direction: column;
}
.notes-expand[open] > summary {
order: 1;
}
.notes-expand[open] > .notes-full {
order: 0;
}
.notes-toggle-label {
display: inline-block;
font-size: var(--text-xs);
color: var(--color-link);
margin-top: var(--space-1);
}
.notes-toggle-label:hover {
text-decoration: underline;
}
.notes-toggle-label::before {
content: "Show more ↓";
}
.notes-expand[open] .notes-toggle-label::before {
content: "Show less ↑";
}
/* --- Release detail --- */
.release-detail {
padding: var(--space-6) 0;
}
.release-header {
margin-bottom: var(--space-6);
}
.release-notes {
margin-bottom: var(--space-6);
}
.release-assets {
margin-bottom: var(--space-6);
}
.release-assets-heading {
font-size: var(--text-base);
font-weight: 600;
margin-bottom: var(--space-3);
}
.asset-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.asset-item {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.asset-item:last-child {
border-bottom: none;
}
.asset-name {
flex: 1;
color: var(--color-link);
text-decoration: none;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.asset-name:hover {
text-decoration: underline;
}
.asset-name-pending {
color: var(--color-text-muted);
cursor: default;
}
.asset-item-pending {
opacity: 0.6;
}
.asset-size,
.asset-meta {
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: nowrap;
}
.release-back {
margin-top: var(--space-4);
display: flex;
gap: var(--space-3);
align-items: center;
justify-content: space-between;
}
}
/* ============================================================
@layer utilities
============================================================ */
@layer utilities {
.text-muted {
color: var(--color-text-muted);
}
.text-sm {
font-size: var(--text-sm);
}
.text-xs {
font-size: var(--text-xs);
}
.font-mono {
font-family: var(--font-mono);
}
.mt-1 {
margin-top: var(--space-1);
}
.mt-2 {
margin-top: var(--space-2);
}
.mt-4 {
margin-top: var(--space-4);
}
.mt-6 {
margin-top: var(--space-6);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.flex {
display: flex;
}
.gap-2 {
gap: var(--space-2);
}
.gap-4 {
gap: var(--space-4);
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.hidden {
display: none;
}
}
/* ============================================================
Pagination
============================================================ */
.commit-verify-hint {
margin-bottom: var(--space-4);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.commit-verify-hint code {
word-break: break-all;
}
.commit-cursor-nav {
display: flex;
justify-content: space-between;
border-top: 1px solid var(--color-border);
margin-top: var(--space-2);
padding: var(--space-4) 0 var(--space-2);
}
.commit-cursor-prev {
flex: 1;
}
.commit-cursor-next {
flex: 1;
text-align: right;
}
.pagination {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
border-top: 1px solid var(--color-border);
margin-top: var(--space-2);
padding: var(--space-4) 0 var(--space-2);
}
.pagination-prev {
justify-self: start;
}
.pagination-next {
justify-self: end;
}
.pagination-info {
justify-self: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.pagination-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
color: var(--color-text);
background: var(--color-bg-subtle);
transition:
background 0.1s,
border-color 0.1s;
white-space: nowrap;
}
.pagination-btn:hover {
background: var(--color-bg-inset);
border-color: var(--color-border);
color: var(--color-text);
text-decoration: none;
}
/* Settings */
.theme-options {
display: flex;
gap: var(--space-4);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.theme-option {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--text-sm);
}
.settings-form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.passkey-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.passkey-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.passkey-item:last-child {
border-bottom: none;
}
.passkey-date {
flex: 1;
color: var(--color-text-muted);
}
.queue-bulk-actions {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.queue-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 32rem;
overflow-y: auto;
}
.queue-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.queue-item-meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.queue-item-header {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.queue-item-date {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.queue-item-answer {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: pre-wrap;
word-break: break-word;
}
.queue-item-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.ssh-key-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.ssh-key-name {
font-weight: 500;
}
.ssh-key-fingerprint {
font-family: var(--font-mono);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-textarea {
resize: vertical;
min-height: 4.5rem;
font-family: var(--font-mono);
font-size: var(--text-sm);
}
/* Clone popup */
.clone-popup-details {
position: relative;
display: inline-block;
}
.clone-popup-details > summary {
list-style: none;
}
.clone-popup-details > summary::-webkit-details-marker {
display: none;
}
.clone-popup {
position: absolute;
right: 1rem;
top: calc(100% + var(--space-2));
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3);
min-width: 22rem;
z-index: 100;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.clone-url-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.clone-url-label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
min-width: 2.75rem;
}
.clone-url-input {
flex: 1;
font-family: var(--font-mono);
font-size: var(--text-sm);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-muted);
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-2);
color: var(--color-text);
cursor: text;
min-width: 0;
}
.user-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-6);
}
.user-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.user-item:last-child {
border-bottom: none;
}
.user-name {
font-weight: 500;
flex: 1;
}
.user-date {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
/* Labels */
.label-badge {
display: inline-block;
padding: 0 var(--space-2);
border-radius: 2em;
font-size: var(--text-xs);
font-weight: 500;
line-height: 1.6;
word-break: break-all;
}
.issue-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.issue-labels-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0 var(--space-3);
}
.label-badge-wrap {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.label-remove-form {
display: inline;
}
.label-remove-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 2px;
font-size: var(--text-sm);
line-height: 1;
color: var(--color-text-muted);
opacity: 0.7;
}
.label-remove-btn:hover {
opacity: 1;
}
.label-add-inline-form {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.label-select {
font-size: var(--text-sm);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
height: 2rem;
}
.label-checkbox-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.label-checkbox-item {
display: inline-flex;
align-items: center;
gap: var(--space-1);
cursor: pointer;
}
.label-settings-list {
display: flex;
flex-direction: column;
margin-bottom: var(--space-4);
max-height: 24rem;
overflow-y: auto;
}
.label-settings-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.label-settings-item:last-child {
border-bottom: none;
}
.label-settings-swatch {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(0, 0, 0, 0.15);
}
.label-settings-name {
flex: 1;
font-weight: 500;
}
.label-add-form {
display: flex;
align-items: center;
gap: var(--space-2);
}
.label-name-input {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
height: 2rem;
box-sizing: border-box;
}
.label-name-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.label-color-swatch-label {
flex-shrink: 0;
cursor: pointer;
display: block;
line-height: 0;
}
.label-color-input {
display: block;
width: 2rem;
height: 2rem;
padding: 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
cursor: pointer;
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
}
/* Label filter (details/summary popup) */
.list-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.label-filter {
position: relative;
}
.label-filter-popup {
position: absolute;
right: 0;
top: calc(100% + var(--space-1));
z-index: 10;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3);
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.label-filter-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-3);
max-height: 16rem;
overflow-y: auto;
}
.label-filter-item {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--text-sm);
}
.label-filter-actions {
display: flex;
gap: var(--space-2);
}
@import "./base.css";
@import "./components.css";
@import "./code.css";
@import "./utilities.css";
Asrc/styles/utilities.css
@@ -0,0 +1,47 @@
@layer utilities {
.text-muted {
color: var(--color-text-muted);
}
.text-sm {
font-size: var(--text-sm);
}
.text-xs {
font-size: var(--text-xs);
}
.font-mono {
font-family: var(--font-mono);
}
.mt-1 {
margin-top: var(--space-1);
}
.mt-2 {
margin-top: var(--space-2);
}
.mt-4 {
margin-top: var(--space-4);
}
.mt-6 {
margin-top: var(--space-6);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.flex {
display: flex;
}
.gap-2 {
gap: var(--space-2);
}
.gap-4 {
gap: var(--space-4);
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.hidden {
display: none;
}
}
Msrc/views/layout.tsx
@@ -1,4 +1,4 @@
import { OWNER_DISPLAY_NAME } from "../config.ts";
import config from "../config.ts";
import { displayName } from "../lib/users.ts";
import type { SessionUser } from "../middleware/session.ts";
import { Avatar } from "./Avatar.tsx";
@@ -80,7 +80,7 @@ export function Layout({ user, title, children }: LayoutProps) {
</header>
<main class="site-main">{children}</main>
<footer class="site-footer">
<p>Hearthforge — hosted by {OWNER_DISPLAY_NAME}</p>
<p>Hearthforge — hosted by {config.OWNER_DISPLAY_NAME}</p>
</footer>
</body>
</html>
Msrc/views/repos/RepoHome.tsx
@@ -1,4 +1,4 @@
import { BASE_URL, SSH_DISABLED, SSH_PORT } from "../../config.ts";
import config from "../../config.ts";
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { TreeEntry } from "../../services/git.ts";
@@ -73,7 +73,7 @@ git push origin main`}</code>
class="clone-url-input"
/>
</div>
{!SSH_DISABLED && (
{!config.SSH_DISABLED && (
<div class="clone-url-row">
<span class="clone-url-label">
SSH
@@ -119,10 +119,10 @@ git push origin main`}</code>
}
function httpUrl(name: string) {
return `${BASE_URL}/${name}.git`;
return `${config.BASE_URL}/${name}.git`;
}
function sshUrl(name: string) {
const host = new URL(BASE_URL).hostname;
return `ssh://git@${host}:${SSH_PORT}/${name}.git`;
const host = new URL(config.BASE_URL).hostname;
return `ssh://git@${host}:${config.SSH_PORT}/${name}.git`;
}
Atests/e2e.auth.test.ts
@@ -0,0 +1,112 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
logout,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
// ─── Auth ─────────────────────────────────────────────────────────────────────
describe('auth', () => {
test('homepage loads', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(BASE);
expect(await page.title()).toContain('Hearthforge');
} finally { await ctx.close(); }
});
test('wrong password shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/login`);
await page.fill('[name=username]', 'admin');
await page.fill('[name=password]', 'wrongpassword');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Invalid');
} finally { await ctx.close(); }
});
test('correct credentials redirect to homepage', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page);
expect(page.url()).toBe(BASE + '/');
expect(await page.locator('.nav-user').isVisible()).toBe(true);
} finally { await ctx.close(); }
});
test('register new user', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
await page.waitForURL(BASE + '/');
expect(await page.locator('.nav-user').textContent()).toBe('alice');
} finally { await ctx.close(); }
});
test('register with mismatched passwords shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'bob');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'different456');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('match');
} finally { await ctx.close(); }
});
test('register with duplicate username shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice'); // already registered above
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('taken');
} finally { await ctx.close(); }
});
test('logout clears session', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page);
await logout(page);
expect(await page.locator('.nav-user').count()).toBe(0);
expect(await page.locator('a[href="/login"]').isVisible()).toBe(true);
} finally { await ctx.close(); }
});
});
Atests/e2e.file-editing.test.ts
@@ -0,0 +1,157 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
DATA_DIR,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
getHeadCommit,
gitOutput,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── File editing ─────────────────────────────────────────────────────────────
describe('file editing', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a dedicated repo so edits don't interfere with other tests
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'edit-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/edit-repo`);
} finally { await page.close(); }
await seedRepo('edit-repo');
});
afterAll(async () => { await adminCtx.close(); });
test('Edit button appears on text file blob when viewing a branch as admin', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
const editBtn = page.locator('a[href*="/edit/main/index.js"]');
expect(await editBtn.isVisible()).toBe(true);
expect(await editBtn.textContent()).toBe('Edit');
} finally { await page.close(); }
});
test('Edit button does not appear when viewing a commit SHA', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`);
expect(await page.locator('a[href*="/edit/"]').count()).toBe(0);
} finally { await page.close(); }
});
test('Edit button does not appear for unauthenticated visitors', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0);
} finally {
await page.close();
await ctx.close();
}
});
test('edit page loads with file content pre-filled', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
const content = await page.locator('textarea[name=content]').inputValue();
expect(content).toContain('hello');
const msg = await page.locator('textarea[name=message]').inputValue();
expect(msg).toBe('Edited index.js');
} finally { await page.close(); }
});
test('edit page shows which branch will be committed to', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
expect(await page.content()).toContain('main');
} finally { await page.close(); }
});
test('edit page returns 404 for non-branch ref', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('submitting edit creates a new commit and redirects to blob view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
await page.fill('textarea[name=content]', 'console.log("edited");\n');
await page.fill('textarea[name=message]', 'Update index.js via web editor');
await page.locator('.form-actions button[type=submit]').click();
await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/);
// The commit detail view should show the commit message
expect(await page.content()).toContain('Update index.js via web editor');
} finally { await page.close(); }
});
test('edit commit has a gpgsig header (is signed)', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`;
const hash = gitOutput(['log', '--format=%H', '--grep=Update index.js via web editor', '-1'], repoDir);
expect(hash).toBeTruthy();
const obj = gitOutput(['cat-file', '-p', hash], repoDir);
expect(obj).toContain('gpgsig');
});
test('edit commit shows verified badge in commit log', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/commits/main`);
const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' });
expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('GET edit page returns 404 for non-branch ref', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
});
Atests/e2e.issues.test.ts
@@ -0,0 +1,446 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
// Create my-repo
const adminCtx = await browser.newContext();
const adminPage = await adminCtx.newPage();
try {
await login(adminPage);
await adminPage.goto(`${BASE}/new`);
await adminPage.fill('[name=name]', 'my-repo');
await adminPage.click('form[action="/new"] button[type=submit]');
await adminPage.waitForURL(`${BASE}/my-repo`);
} finally { await adminCtx.close(); }
await seedRepo('my-repo');
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Issues ───────────────────────────────────────────────────────────────────
describe('issues', () => {
let adminCtx: BrowserContext;
let issueUrl: string;
let completedIssueUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
});
afterAll(async () => { await adminCtx.close(); });
test('create issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'First issue');
await page.fill('[name=body]', 'Body with **markdown**.');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
issueUrl = page.url();
expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
} finally { await page.close(); }
});
test('issue body renders markdown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
} finally { await page.close(); }
});
test('issue appears in open list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('First issue'))).toBe(true);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from new issue form', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('add comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const beforeCount = await page.locator('.timeline-item').count();
await page.fill('textarea[name=body]', 'A follow-up comment.');
await page.click('form[action*="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
} finally { await page.close(); }
});
test('react to issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.locator('.reaction-picker').first().click();
await page.locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('close issue changes status badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('closed');
} finally { await page.close(); }
});
test('closed issue appears in closed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues?status=closed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('First issue'))).toBe(true);
} finally { await page.close(); }
});
test('reopen issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('.issue-detail-meta-actions button');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('open');
} finally { await page.close(); }
});
test('completed button marks issue as completed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'To be completed');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
completedIssueUrl = page.url();
await page.click('form[action*="/complete"] button');
await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('completed');
} finally { await page.close(); }
});
test('completed issue appears in completed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues?status=completed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('To be completed'))).toBe(true);
} finally { await page.close(); }
});
test('non-admin cannot complete or close issue', async () => {
const issueNum = issueUrl.split('/issues/')[1];
const ctx = await browser.newContext();
try {
const completeResp = await ctx.request.post(
`${BASE}/my-repo/issues/${issueNum}/complete`,
{ maxRedirects: 0 },
);
expect(completeResp.status()).toBe(302);
expect(completeResp.headers()['location']).toContain('/login');
} finally { await ctx.close(); }
});
test('reacting with same emoji toggles it off', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Reaction was added by the earlier 'react to issue' test
expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
await page.locator('.reaction-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').count()).toBe(0);
} finally { await page.close(); }
});
test('react to issue comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)')
.filter({ hasText: 'A follow-up comment.' });
await commentItem.locator('.reaction-add-btn').click();
await commentItem.locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
});
// ─── Issue editing and deletion ───────────────────────────────────────────────
describe('issue editing', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let issueUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create an issue to edit
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'Issue to edit');
await page.fill('[name=body]', 'Original body.');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
issueUrl = page.url();
} finally { await page.close(); }
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('author can edit issue title and body', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Edit title via title form
await page.click('details.title-edit-details summary');
await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
await page.click('.title-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
// Edit body via inline form
await page.click('.timeline-author .inline-edit-details summary');
await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
await page.click('.inline-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('edited marker appears after editing', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('non-author non-admin cannot edit issue', async () => {
const page = await aliceCtx.newPage();
try {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
form: { title: 'Hacked title', edit_body: '' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('author can edit issue comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Add a comment first
await page.fill('textarea[name=body]', 'Comment to edit.');
await page.click('form[action*="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
// Edit the comment
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
await commentItem.locator('.inline-edit-details summary').click();
await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
await commentItem.locator('.inline-edit-form-area [type=submit]').click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
} finally { await page.close(); }
});
test('non-admin user can create an issue', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', "Alice's issue");
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
} finally { await page.close(); }
});
test('non-admin cannot comment on a closed issue', async () => {
// Close the issue as admin first
const issueNum = issueUrl.split('/issues/')[1];
await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
form: { body: 'comment on closed issue' },
maxRedirects: 0,
});
// Non-admin gets redirected (silently ignored), not an error
expect(resp.status()).toBe(302);
// The comment should NOT appear
await page.goto(issueUrl);
const bodies = await page.locator('.timeline-body').allTextContents();
expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
} finally { await page.close(); }
});
test('admin can delete issue', async () => {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
// Issue should be gone
const page = await adminCtx.newPage();
try {
const checkResp = await page.request.get(issueUrl);
expect(checkResp.status()).toBe(404);
} finally { await page.close(); }
});
});
// ─── Repository description update ───────────────────────────────────────────
describe('repo description', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('updating repo description is reflected on list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=description]', 'A freshly updated description');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const desc = await page.locator('.repo-description').allTextContents();
expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
} finally { await page.close(); }
});
});
// ─── Issue and patch templates ────────────────────────────────────────────────
describe('issue and patch templates', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('issue template can be saved and is prefilled on new issue form', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/issues/new`);
const body = await page.locator('[name=body]').inputValue();
expect(body).toContain('## Steps to reproduce');
expect(body).toContain('## Expected behavior');
} finally { await page.close(); }
});
test('patch template can be saved and is prefilled on new patch form', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/patches/new`);
const desc = await page.locator('[name=description]').inputValue();
expect(desc).toContain('## Summary');
expect(desc).toContain('## Testing');
} finally { await page.close(); }
});
test('clearing the issue template removes prefill', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=issue_template]', '');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/issues/new`);
const body = await page.locator('[name=body]').inputValue();
expect(body).toBe('');
} finally { await page.close(); }
});
test('clearing the patch template removes prefill', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=patch_template]', '');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/patches/new`);
const desc = await page.locator('[name=description]').inputValue();
expect(desc).toBe('');
} finally { await page.close(); }
});
});
Atests/e2e.labels.test.ts
@@ -0,0 +1,614 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
writeTempFile,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice (needed for user label management)
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Labels ───────────────────────────────────────────────────────────────────
describe('labels', () => {
let adminCtx: BrowserContext;
let issueUrl: string;
let patchUrl: string;
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add label-test.txt',
'',
'---',
'diff --git a/label-test.txt b/label-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/label-test.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a dedicated repo for label tests
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'label-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/label-repo`);
} finally { await page.close(); }
// Create an issue
const issuePage = await adminCtx.newPage();
try {
await issuePage.goto(`${BASE}/label-repo/issues/new`);
await issuePage.fill('[name=title]', 'Labelled issue');
await issuePage.click('form[action$="/issues"] button[type=submit]');
await issuePage.waitForURL(/\/label-repo\/issues\/\d+/);
issueUrl = issuePage.url();
} finally { await issuePage.close(); }
// Create a patch
writeTempFile('/tmp/label-test.patch', VALID_PATCH);
const patchPage = await adminCtx.newPage();
try {
await patchPage.goto(`${BASE}/label-repo/patches/new`);
await patchPage.fill('[name=title]', 'Labelled patch');
await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch');
await patchPage.click('form[action$="/patches"] button[type=submit]');
await patchPage.waitForURL(/\/label-repo\/patches\/\d+/);
patchUrl = patchPage.url();
} finally { await patchPage.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('create label in repo settings', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'bug');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#ff0000'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('create a second label', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'enhancement');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#00aa00'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
const badges = await page.locator('.label-settings-name').allTextContents();
expect(badges).toContain('bug');
expect(badges).toContain('enhancement');
} finally { await page.close(); }
});
test('duplicate label name is rejected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'bug');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#0000ff'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
expect(await page.locator('.form-error').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('non-admin cannot create labels', async () => {
const ctx = await browser.newContext();
try {
const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, {
form: { name: 'nope', color: '#123456' },
maxRedirects: 0,
});
// Unauthenticated → redirected to /login
expect(r.status()).toBe(302);
expect(r.headers()['location']).toContain('/login');
} finally { await ctx.close(); }
});
test('assign label to issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.selectOption('select[name=label_id]', { label: 'bug' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('label appears on issue list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' });
expect(await item.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('filter issues by label shows only matching issues', async () => {
// Create a second issue without the label
const createPage = await adminCtx.newPage();
try {
await createPage.goto(`${BASE}/label-repo/issues/new`);
await createPage.fill('[name=title]', 'Unlabelled issue');
await createPage.click('form[action$="/issues"] button[type=submit]');
await createPage.waitForURL(/\/label-repo\/issues\/\d+/);
} finally { await createPage.close(); }
// Open filter popup and apply label filter
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
// Get the label id from the checkbox
const checkbox = page.locator('.label-filter-item input[name=labels]').first();
const labelId = await checkbox.getAttribute('value');
expect(labelId).toBeTruthy();
// Navigate with the filter applied via URL
await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Labelled issue'))).toBe(true);
expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false);
} finally { await page.close(); }
});
test('filter popup is visible without JS', async () => {
// details/summary is a native HTML element — verify it renders
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
expect(await page.locator('details.label-filter').isVisible()).toBe(true);
expect(await page.locator('details.label-filter summary').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('remove label from issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
// Label badge should no longer appear in the labels row
const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(labelBadges).not.toContain('bug');
} finally { await page.close(); }
});
test('assign label to patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(patchUrl);
await page.selectOption('select[name=label_id]', { label: 'enhancement' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement');
} finally { await page.close(); }
});
test('label appears on patch list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/patches`);
const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' });
expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement');
} finally { await page.close(); }
});
test('filter patches by label', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/patches`);
// Find the checkbox for the 'enhancement' label specifically
const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' })
.locator('input[name=labels]');
const labelId = await checkbox.getAttribute('value');
expect(labelId).toBeTruthy();
await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Labelled patch'))).toBe(true);
} finally { await page.close(); }
});
test('remove label from patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(patchUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(labelBadges).not.toContain('enhancement');
} finally { await page.close(); }
});
test('delete label removes it from settings list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' });
await bugItem.locator('form[action$="/labels/delete"] button').click();
await page.waitForURL(/\/label-repo\/settings/);
const badges = await page.locator('.label-settings-name').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
});
test('deleted label no longer appears in filter popup', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
const filterLabels = await page.locator('.label-filter-item').allTextContents();
expect(filterLabels.every(t => !t.includes('bug'))).toBe(true);
} finally { await page.close(); }
});
});
// ─── User label management ────────────────────────────────────────────────────
describe('user label management', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
// URL of an issue owned by alice
let aliceIssueUrl: string;
// URL of an issue owned by admin
let adminIssueUrl: string;
// label IDs, fetched from the filter inputs
let bugLabelId: string;
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add ulm-test.txt',
'',
'---',
'diff --git a/ulm-test.txt b/ulm-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/ulm-test.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create dedicated repo
const repoPage = await adminCtx.newPage();
try {
await repoPage.goto(`${BASE}/new`);
await repoPage.fill('[name=name]', 'ulm-repo');
await repoPage.click('form[action="/new"] button[type=submit]');
await repoPage.waitForURL(`${BASE}/ulm-repo`);
} finally { await repoPage.close(); }
// Create labels 'bug' and 'feature'
for (const name of ['bug', 'feature']) {
const p = await adminCtx.newPage();
try {
await p.goto(`${BASE}/ulm-repo/settings`);
await p.fill('input[name=name]', name);
await p.click('form[action$="/settings/labels"] button[type=submit]');
await p.waitForURL(/\/ulm-repo\/settings/);
} finally { await p.close(); }
}
// Create an issue owned by admin
const adminIssuePage = await adminCtx.newPage();
try {
await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
await adminIssuePage.fill('[name=title]', "Admin's issue");
await adminIssuePage.click('form[action$="/issues"] button[type=submit]');
await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
adminIssueUrl = adminIssuePage.url();
} finally { await adminIssuePage.close(); }
// Create an issue owned by alice
const aliceIssuePage = await aliceCtx.newPage();
try {
await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
await aliceIssuePage.fill('[name=title]', "Alice's issue");
await aliceIssuePage.click('form[action$="/issues"] button[type=submit]');
await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
aliceIssueUrl = aliceIssuePage.url();
} finally { await aliceIssuePage.close(); }
// Grab the bug label id from the filter popup
const filterPage = await adminCtx.newPage();
try {
await filterPage.goto(`${BASE}/ulm-repo/issues`);
const checkbox = filterPage.locator('.label-filter-item')
.filter({ hasText: 'bug' })
.locator('input[name=labels]');
bugLabelId = (await checkbox.getAttribute('value')) ?? '';
} finally { await filterPage.close(); }
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
// ── Settings ──
test('allow_user_labels checkbox is present in repo settings', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1);
} finally { await page.close(); }
});
test('allow_user_labels is off by default', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
} finally { await page.close(); }
});
// ── Label checkboxes hidden when setting is off ──
test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
test('label checkboxes shown to admin on new issue form regardless of setting', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Enable the setting ──
test('admin can enable allow_user_labels', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
await page.check('input[name=allow_user_labels]');
await page.click('form[action$="/settings"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/settings/);
// Verify it persisted
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true);
} finally { await page.close(); }
});
// ── Label checkboxes visible when setting is on ──
test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents();
expect(labels).toContain('bug');
expect(labels).toContain('feature');
} finally { await page.close(); }
});
test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Creating with labels selected ──
test('non-admin can create issue with label selected', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
await page.fill('[name=title]', 'Issue with label');
// Check the 'bug' label checkbox
await page.locator('.label-checkbox-item').filter({ hasText: 'bug' })
.locator('input[type=checkbox]').check();
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/issues\/\d+/);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).toContain('bug');
} finally { await page.close(); }
});
test('non-admin can create patch with label selected', async () => {
writeTempFile('/tmp/ulm-test.patch', VALID_PATCH);
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
await page.fill('[name=title]', 'Patch with label');
await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch');
await page.locator('.label-checkbox-item').filter({ hasText: 'feature' })
.locator('input[type=checkbox]').check();
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/patches\/\d+/);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).toContain('feature');
} finally { await page.close(); }
});
test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => {
// Temporarily disable the setting, post with label_ids, re-enable
await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
form: { description: '', default_branch: 'main' }, // no allow_user_labels
maxRedirects: 0,
}).catch(() => {});
const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, {
form: { title: 'Issue sneaking labels', label_ids: bugLabelId },
maxRedirects: 0,
}).catch(() => null);
// Should redirect to the new issue
const location = resp?.headers()['location'] ?? '';
const issueNum = location.split('/issues/')[1];
if (issueNum) {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
}
// Re-enable for subsequent tests
await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
form: { description: '', default_branch: 'main', allow_user_labels: '1' },
maxRedirects: 0,
}).catch(() => {});
});
// ── Add/remove labels on existing items ──
test('non-admin can add label to their own issue', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const page = await aliceCtx.newPage();
try {
await page.goto(aliceIssueUrl);
await page.selectOption('select[name=label_id]', { label: 'bug' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('non-admin can remove label from their own issue', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const page = await aliceCtx.newPage();
try {
await page.goto(aliceIssueUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
const badges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
});
test('non-admin cannot add label to another user\'s issue', async () => {
const issueNum = adminIssueUrl.split('/issues/')[1];
const resp = await aliceCtx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(403);
});
test('unauthenticated user gets 401 adding a label', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const ctx = await browser.newContext();
try {
const resp = await ctx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(401);
} finally { await ctx.close(); }
});
// ── Disable setting and verify enforcement ──
test('admin can disable allow_user_labels', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
await page.uncheck('input[name=allow_user_labels]');
await page.click('form[action$="/settings"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/settings/);
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
} finally { await page.close(); }
});
test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const resp = await aliceCtx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(403);
});
test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
});
Atests/e2e.pagination.test.ts
@@ -0,0 +1,200 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// Helper: create N issues in a repo using the server API (no browser rendering)
async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) {
for (let i = 1; i <= count; i++) {
await ctx.request.fetch(`${BASE}/${repo}/issues`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `title=Issue+number+${i}&body=`,
maxRedirects: 0,
}).catch(() => {}); // 302 redirect throws; that's fine
}
}
// Helper: create N patches in a repo using the server API
async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
for (let i = 1; i <= count; i++) {
await ctx.request.fetch(`${BASE}/${repo}/patches`, {
method: 'POST',
multipart: {
title: `Patch number ${i}`,
patch_file: { name: 'bulk.patch', mimeType: 'text/plain', buffer: Buffer.from(VALID_PATCH) },
},
maxRedirects: 0,
}).catch(() => {});
}
}
// ─── Pagination ───────────────────────────────────────────────────────────────
describe('pagination', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a repo dedicated to pagination testing
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'paged-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/paged-repo`);
} finally { await page.close(); }
// Create 21 issues via the API (triggers page 2 at 20 per page)
await bulkCreateIssues(adminCtx, 'paged-repo', 21);
// Create 21 patches via browser (patch upload requires multipart)
await bulkCreatePatches(adminCtx, 'paged-repo', 21);
});
afterAll(async () => { await adminCtx.close(); });
// ── Repo list pagination ──────────────────────────────────────────────────
test('repo list page 1 shows repos and no pagination when few repos', async () => {
// With only a handful of test repos (< 20), there should be no pagination nav
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
// Repos are shown
expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
// ── Issue pagination ──────────────────────────────────────────────────────
test('issue list page 1 shows at most 20 items', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('issue list pagination nav appears when more than 20 issues', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('issue list page 2 shows remaining issues', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('issue list page 2 prev link goes to page 1', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues?page=2`);
const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
expect(prevHref).toContain('page=1');
} finally { await page.close(); }
});
test('issue list page 1 next link goes to page 2', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
expect(nextHref).toContain('page=2');
} finally { await page.close(); }
});
// ── Patch pagination ──────────────────────────────────────────────────────
test('patch list page 1 shows at most 20 items', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('patch list pagination nav appears when more than 20 patches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch list page 2 shows remaining patches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
} finally { await page.close(); }
});
// ── Commit log pagination ─────────────────────────────────────────────────
test('commit log with few commits shows no cursor nav', async () => {
// paged-repo has no commits (empty repo) — there's no commit log to paginate
// We verify the page loads without pagination controls
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo`);
expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
} finally { await page.close(); }
});
});
Atests/e2e.patches.test.ts
@@ -0,0 +1,718 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import { existsSync, readFileSync } from 'node:fs';
import {
BASE,
DATA_DIR,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
writeTempFile,
gitOutput,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
// Create my-repo
const adminCtx = await browser.newContext();
const adminPage = await adminCtx.newPage();
try {
await login(adminPage);
await adminPage.goto(`${BASE}/new`);
await adminPage.fill('[name=name]', 'my-repo');
await adminPage.click('form[action="/new"] button[type=submit]');
await adminPage.waitForURL(`${BASE}/my-repo`);
} finally { await adminCtx.close(); }
await seedRepo('my-repo');
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Patches ──────────────────────────────────────────────────────────────────
describe('patches', () => {
let adminCtx: BrowserContext;
let cleanPatchUrl: string;
let conflictPatchUrl: string;
let closePatchUrl: string;
// Adds a new file — applies cleanly to my-repo
const CLEAN_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add patch-test.txt',
'',
'---',
'diff --git a/patch-test.txt b/patch-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/patch-test.txt',
'@@ -0,0 +1 @@',
'+patch test content',
'',
].join('\n');
// References non-existent lines in README.md — always conflicts
const CONFLICT_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Modify README',
'',
'---',
'diff --git a/README.md b/README.md',
'index abc1234..def5678 100644',
'--- a/README.md',
'+++ b/README.md',
'@@ -50,3 +50,3 @@',
' nonexistent context line',
'-nonexistent old line',
'+nonexistent new line',
'',
].join('\n');
// Adds another new file — for testing close flow
const CLOSE_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add patch-close.txt',
'',
'---',
'diff --git a/patch-close.txt b/patch-close.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/patch-close.txt',
'@@ -0,0 +1 @@',
'+close test',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
});
afterAll(async () => { await adminCtx.close(); });
test('reject file without patch markers', async () => {
writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Bad patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('valid patch');
} finally { await page.close(); }
});
test('reject patch missing Subject header', async () => {
writeTempFile('/tmp/no-subject.patch', [
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No subject');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Subject');
} finally { await page.close(); }
});
test('reject patch missing From header', async () => {
writeTempFile('/tmp/no-from.patch', [
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No from');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('From');
} finally { await page.close(); }
});
test('reject patch missing Date header', async () => {
writeTempFile('/tmp/no-date.patch', [
'From: Test User <test@example.com>',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No date');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Date');
} finally { await page.close(); }
});
test('upload clean patch', async () => {
writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Add patch-test.txt');
await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
cleanPatchUrl = page.url();
expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
} finally { await page.close(); }
});
test('changes tab shows commit metadata card', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl + '?tab=changes');
expect(await page.locator('.commit-card').isVisible()).toBe(true);
expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch description renders markdown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
} finally { await page.close(); }
});
test('clean patch shows apply-clean status immediately', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.apply-result').isVisible()).toBe(true);
expect(await page.locator('.apply-clean').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('merge button appears for clean patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch diff is displayed with highlighted table', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl + '?tab=changes');
expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('patch appears in open list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from patch upload', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('upload conflict patch', async () => {
writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Conflict patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
conflictPatchUrl = page.url();
} finally { await page.close(); }
});
test('conflict patch shows apply-conflict status', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('merge button absent for conflict patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
} finally { await page.close(); }
});
test('merge clean patch changes status to merged', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
await page.click('form[action*="/merge"] button');
await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('merged');
} finally { await page.close(); }
});
test('merge uses patch From header as git author', async () => {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath);
const authorEmail = gitOutput(['log', '-1', '--format=%aE'], repoPath);
const subject = gitOutput(['log', '-1', '--format=%s'], repoPath);
expect(authorName).toBe('Test User');
expect(authorEmail).toBe('test@example.com');
expect(subject).toBe('Add patch-test.txt');
});
test('merged patch appears in merged list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches?status=merged`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
} finally { await page.close(); }
});
test('upload and close a patch', async () => {
writeTempFile('/tmp/close.patch', CLOSE_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Close me');
await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
closePatchUrl = page.url();
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('closed');
} finally { await page.close(); }
});
test('closed patch appears in closed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches?status=closed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Close me'))).toBe(true);
} finally { await page.close(); }
});
test('closed patch can be reopened', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(closePatchUrl);
expect(await page.locator('.patch-badge').textContent()).toBe('closed');
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('open');
} finally { await page.close(); }
});
test('patch title and description can be edited', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
// Edit title via title form
await page.click('details.title-edit-details summary');
await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
await page.click('.title-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
// Edit description via inline form
await page.click('.timeline-author .inline-edit-details summary');
await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
await page.click('.inline-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('patch comment: add and edit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
await page.fill('[name=body]', 'My patch comment');
await page.click('form[action$="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
// Edit the comment — scope to the timeline-item containing the comment text
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
await commentItem.locator('.inline-edit-details summary').click();
await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
await commentItem.locator('.inline-edit-form-area [type=submit]').click();
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
} finally { await page.close(); }
});
test('patch reaction on description', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
// Open reaction picker on the first timeline-item (description)
await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
} finally { await page.close(); }
});
test('admin can delete a patch', async () => {
const page = await adminCtx.newPage();
try {
const patchNum = conflictPatchUrl.split('/patches/')[1];
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('/patches');
// Patch should no longer be accessible
const checkResp = await page.request.get(conflictPatchUrl);
expect(checkResp.status()).toBe(404);
} finally { await page.close(); }
});
// ── Patch file re-upload & version protection ──────────────────────────────
let uploadTestPatchUrl: string;
// Adds upload-test.txt — applies cleanly to my-repo
const UPLOAD_TEST_PATCH = [
'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001',
'From: Original Author <original@example.com>',
'Date: Wed, 03 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add upload-test.txt',
'',
'---',
'diff --git a/upload-test.txt b/upload-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/upload-test.txt',
'@@ -0,0 +1 @@',
'+upload test',
'',
].join('\n');
// Replacement: different author, same diff target
const REPLACEMENT_PATCH = [
'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001',
'From: Replaced Author <replaced@example.com>',
'Date: Thu, 04 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add upload-test.txt (v2)',
'',
'---',
'diff --git a/upload-test.txt b/upload-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/upload-test.txt',
'@@ -0,0 +1 @@',
'+upload test v2',
'',
].join('\n');
test('create patch for re-upload tests', async () => {
writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Upload test patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
uploadTestPatchUrl = page.url();
} finally { await page.close(); }
});
test('upload patch file button is visible for admin on open patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(1);
} finally { await page.close(); }
});
test('non-author non-admin cannot upload patch file', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
const patchNum = uploadTestPatchUrl.split('/patches/')[1];
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await aliceCtx.close(); }
});
test('upload button hidden for non-author non-admin', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
} finally { await aliceCtx.close(); }
});
test('admin can upload replacement patch file', async () => {
writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
await page.locator('details:has([name=patch_file]) summary').click();
await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch');
await page.locator('details:has([name=patch_file]) button[type=submit]').click();
await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('merge fails when version token is stale', async () => {
// Patch that adds stale-version.txt — applies cleanly
const STALE_PATCH = [
'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Fri, 05 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add stale-version.txt',
'',
'---',
'diff --git a/stale-version.txt b/stale-version.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/stale-version.txt',
'@@ -0,0 +1 @@',
'+stale',
'',
].join('\n');
const STALE_PATCH_V2 = STALE_PATCH
.replace('Add stale-version.txt', 'Add stale-version.txt (v2)')
.replace('+stale', '+stale v2');
writeTempFile('/tmp/stale.patch', STALE_PATCH);
const page = await adminCtx.newPage();
try {
// Create the patch
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Stale version test');
await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
const stalePatchUrl = page.url();
const patchNum = stalePatchUrl.split('/patches/')[1];
// Capture the version the admin sees on the page
const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue();
// Author uploads a new patch file (simulated by admin here), bumping the version
writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2);
await page.locator('details:has([name=patch_file]) summary').click();
await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch');
await page.locator('details:has([name=patch_file]) button[type=submit]').click();
await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, '')));
// Admin tries to merge with the stale version — should be rejected
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, {
form: { version: staleVersion },
maxRedirects: 0,
});
expect(resp.status()).toBe(409);
expect(await resp.text()).toContain('updated');
// Patch status must still be open
const checkResp = await page.request.get(stalePatchUrl);
expect(checkResp.status()).toBe(200);
expect(await checkResp.text()).toContain('open');
} finally { await page.close(); }
});
test('merge succeeds with current version token after replacement upload', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
await page.click('form[action*="/merge"] button');
await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('merged');
// Merged commit should carry the replacement patch's author
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath);
expect(authorName).toBe('Replaced Author');
} finally { await page.close(); }
});
test('upload patch file button hidden on merged patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
} finally { await page.close(); }
});
test('POST to upload on merged patch returns 400', async () => {
const patchNum = uploadTestPatchUrl.split('/patches/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
maxRedirects: 0,
});
expect(resp.status()).toBe(400);
});
});
// ─── Commit signing ───────────────────────────────────────────────────────────
// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
describe('commit signing', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('allowed_signers file is generated at startup', () => {
const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
expect(existsSync(allowedSignersPath)).toBe(true);
const content = readFileSync(allowedSignersPath, 'utf8');
expect(content).toContain('namespaces="git"');
expect(content).toContain('ssh-ed25519');
});
test('merged commit has a gpgsig header', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
// Find the patch commit specifically by subject
const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir);
expect(hash).toBeTruthy();
const obj = gitOutput(['cat-file', '-p', hash], repoDir);
expect(obj).toContain('gpgsig');
});
test('unsigned commits have no gpgsig header', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
// Initial commit was created by seedRepo (plain git commit, not hearthforge)
const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir);
expect(hash).toBeTruthy();
const obj = gitOutput(['cat-file', '-p', hash], repoDir);
expect(obj).not.toContain('gpgsig');
});
test('commit log shows verified badge on signed commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
// Find the commit-item for the merged patch by subject text
const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('commit log shows no sig badge on unsigned commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
// Initial commit was not signed via hearthforge
const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
expect(await initialItem.locator('.sig-badge').count()).toBe(0);
} finally { await page.close(); }
});
test('commit detail shows verified signature row for signed commit', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commit/${hash}`);
const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
expect(await sigRow.isVisible()).toBe(true);
expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('commit detail shows no signature row for unsigned commit', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commit/${hash}`);
const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
expect(await sigRow.count()).toBe(0);
} finally { await page.close(); }
});
});
Atests/e2e.releases.test.ts
@@ -0,0 +1,361 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Releases ─────────────────────────────────────────────────────────────────
describe('releases', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let releaseUrl: string;
let srcReleaseUrl: string;
let releaseWithAssetsUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create a dedicated repo with at least one commit
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'releases-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/releases-repo`);
} finally { await page.close(); }
await seedRepo('releases-repo');
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
// ── Navigation ──────────────────────────────────────────────────────────────
test('releases tab visible in repo nav', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo`);
expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
} finally { await page.close(); }
});
test('releases list shows empty state when no releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Access control ──────────────────────────────────────────────────────────
test('non-admin cannot access /releases/new', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('non-admin POST to /releases returns 403', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Test', tag_name: 'v0.1.0' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from /releases/new', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
// ── Validation ──────────────────────────────────────────────────────────────
test('missing release title shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {},
});
expect(await resp.text()).toContain('Release title is required');
});
test('create_tag checked but no tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Test', create_tag: 'on' },
});
expect(await resp.text()).toContain('Tag name is required');
});
// ── Create ──────────────────────────────────────────────────────────────────
test('create a basic release', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
create_tag: 'on',
tag_name: 'v1.0.0',
revision: 'main',
name: 'First release',
notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
},
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
releaseUrl = `${BASE}${location}`;
});
test('duplicate tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' },
});
expect(await resp.text()).toContain('already exists');
});
// ── List ────────────────────────────────────────────────────────────────────
test('release appears in list with tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.release-item-title').textContent()).toContain('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('release list shows tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('new release button hidden for non-admin', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
} finally { await page.close(); }
});
// ── Detail ──────────────────────────────────────────────────────────────────
test('release detail shows title and tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('h2.page-title').textContent()).toBe('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('release notes rendered in detail view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
} finally { await page.close(); }
});
// ── Source archives ─────────────────────────────────────────────────────────
test('create release with source code archives', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
srcReleaseUrl = `${BASE}${location}`;
});
test('zip and tar.gz archives appear in downloads', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const assetNames = await page.locator('.asset-name').allTextContents();
expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
} finally { await page.close(); }
});
test('source archive download responds with 200', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
const resp = await page.request.get(`${BASE}${zipLink}`);
expect(resp.status()).toBe(200);
} finally { await page.close(); }
});
// ── File upload ─────────────────────────────────────────────────────────────
test('create release with attached file', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
name: 'Asset release',
create_tag: 'on',
tag_name: 'v1.2.0',
revision: 'main',
files: {
name: 'release-asset.txt',
mimeType: 'text/plain',
buffer: Buffer.from('binary-like content for testing\n'),
},
},
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
releaseWithAssetsUrl = `${BASE}${location}`;
});
test('uploaded asset appears in downloads with filename and size', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseWithAssetsUrl);
const names = await page.locator('.asset-name').allTextContents();
expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
expect(await page.locator('.asset-size').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('asset download responds with 200', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseWithAssetsUrl);
const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
const resp = await page.request.get(`${BASE}${link}`);
expect(resp.status()).toBe(200);
} finally { await page.close(); }
});
// ── Delete ──────────────────────────────────────────────────────────────────
test('non-admin cannot delete a release', async () => {
const page = await aliceCtx.newPage();
try {
const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('admin can delete a release', async () => {
const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
const resp = await adminCtx.request.post(
`${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
{ maxRedirects: 0 },
);
expect(resp.status()).toBe(302);
// Verify it's gone from the list
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
const titles = await page.locator('.release-item-title').allTextContents();
expect(titles.some(t => t.includes('First release'))).toBe(false);
} finally { await page.close(); }
});
// ── Pagination ──────────────────────────────────────────────────────────────
test('release list shows at most 20 per page', async () => {
// Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
// regardless of which browser-based tests above succeeded
for (let i = 1; i <= 25; i++) {
await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' },
maxRedirects: 0,
}).catch(() => {});
}
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('pagination nav appears with more than 20 releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('release list page 2 shows remaining releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('source archive tar.zst appears in downloads', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const assetNames = await page.locator('.asset-name').allTextContents();
expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
} finally { await page.close(); }
});
});
Atests/e2e.repos.test.ts
@@ -0,0 +1,343 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
await page.waitForURL(BASE + '/');
} finally { await ctx.close(); }
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Repos ────────────────────────────────────────────────────────────────────
describe('repos', () => {
// Shared admin context — cookies persist across tests in this block.
let adminCtx: BrowserContext;
// Alice's context, created after alice is registered in auth tests.
let aliceCtx: BrowserContext;
// Set after 'commit log' test; used by all commit-detail tests below.
let commitUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('non-admin gets 403 on /new', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/new`);
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('create repository', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'my-repo');
await page.fill('[name=description]', 'A test repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/my-repo`);
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('repository appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
} finally { await page.close(); }
});
test('search finds matching repo', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await page.fill('[name=q]', 'my-repo');
await page.click('.search-form button[type=submit]');
expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
} finally { await page.close(); }
});
test('search returns empty for unknown term', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await page.fill('[name=q]', 'zzz-nothing-here');
await page.click('.search-form button[type=submit]');
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('browse file tree after seeding content', async () => {
await seedRepo('my-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
const files = await page.locator('.file-name a').allTextContents();
expect(files).toContain('README.md');
expect(files).toContain('index.js');
} finally { await page.close(); }
});
test('view file blob with syntax highlighting', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/blob/main/index.js`);
expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('raw file download responds 200', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
expect(resp.status()).toBe(200);
expect(resp.headers()['content-disposition']).toContain('README.md');
} finally { await page.close(); }
});
test('commit log shows initial commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
const subjects = await page.locator('.commit-subject').allTextContents();
expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
// Navigate to the commit page and capture the URL for subsequent tests
await page.locator('.commit-hash').first().click();
await page.waitForURL(/\/my-repo\/commit\//);
commitUrl = page.url();
} finally { await page.close(); }
});
test('commit detail shows metadata card', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
expect(await page.locator('.commit-card').isVisible()).toBe(true);
expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
// Author, date and SHA rows are all present
const metaText = await page.locator('.commit-card-meta').textContent();
expect(metaText).toContain('Test'); // author name set by seedRepo
expect(metaText).toContain('Author'); // label (CSS uppercases visually)
expect(metaText).toContain('Date');
expect(metaText).toContain('Commit');
} finally { await page.close(); }
});
test('commit detail full SHA is shown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const sha = commitUrl.split('/commit/')[1] ?? null;
expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
} finally { await page.close(); }
});
test('commit detail shows file nav sidebar', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
const navItems = await page.locator('.file-nav-item').allTextContents();
// seedRepo adds README.md and index.js
expect(navItems.some(t => t.includes('README.md'))).toBe(true);
expect(navItems.some(t => t.includes('index.js'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail file nav items are anchor links to diff sections', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const hrefs = await page.locator('.file-nav-item').evaluateAll(
els => els.map(el => el.getAttribute('href') ?? ''),
);
expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail shows diff table with added lines', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// Initial commit only adds lines
expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
expect(await page.locator('.diff-row-del').count()).toBe(0);
} finally { await page.close(); }
});
test('commit detail diff table has line numbers', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// New-side line numbers (column 2) on add rows start at 1
const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
expect(firstNewLn?.trim()).toBe('1');
} finally { await page.close(); }
});
test('commit detail shows added stats on file header', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const addStats = await page.locator('.diff-stat-add').allTextContents();
expect(addStats.length).toBeGreaterThan(0);
expect(addStats.every(s => s.startsWith('+'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail view-at-sha button links to blob at that commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const sha = commitUrl.split('/commit/')[1];
const btn = page.locator('.btn-xs').first();
const href = await btn.getAttribute('href');
expect(href).toContain(`/blob/${sha}/`);
} finally { await page.close(); }
});
test('commit detail view-at-branch button links to blob at default branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const btns = await page.locator('.btn-xs').allTextContents();
expect(btns.some(t => t.includes('@ main'))).toBe(true);
const branchBtns = await page.locator('.btn-xs').evaluateAll(
els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
);
expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail file diff can be collapsed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// File body is visible when details is open
const diffFile = page.locator('.diff-file').first();
expect(await diffFile.getAttribute('open')).not.toBeNull();
// Click the summary to collapse
await diffFile.locator('.diff-file-header').click();
expect(await diffFile.getAttribute('open')).toBeNull();
} finally { await page.close(); }
});
test('commit detail file nav sidebar can be collapsed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const nav = page.locator('.file-nav-details');
expect(await nav.getAttribute('open')).not.toBeNull();
await nav.locator('.file-nav-toggle').click();
expect(await nav.getAttribute('open')).toBeNull();
} finally { await page.close(); }
});
test('readme renders on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.readme-header').isVisible()).toBe(true);
expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
} finally { await page.close(); }
});
test('private repo hidden from other users', async () => {
// Make private
const adminPage = await adminCtx.newPage();
try {
await adminPage.goto(`${BASE}/my-repo/settings`);
await adminPage.check('[name=is_private]');
await adminPage.click('form[action$="/settings"] button[type=submit]');
expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
} finally { await adminPage.close(); }
// Alice should get 404
const alicePage = await aliceCtx.newPage();
try {
const resp = await alicePage.request.get(`${BASE}/my-repo`);
expect(resp.status()).toBe(404);
await alicePage.goto(BASE);
expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
} finally { await alicePage.close(); }
// Restore to public
const adminPage2 = await adminCtx.newPage();
try {
await adminPage2.goto(`${BASE}/my-repo/settings`);
await adminPage2.uncheck('[name=is_private]');
await adminPage2.click('form[action$="/settings"] button[type=submit]');
} finally { await adminPage2.close(); }
});
test('settings tab visible for admin, hidden for others', async () => {
const adminPage = await adminCtx.newPage();
try {
await adminPage.goto(`${BASE}/my-repo`);
expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
} finally { await adminPage.close(); }
const alicePage = await aliceCtx.newPage();
try {
await alicePage.goto(`${BASE}/my-repo`);
expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
} finally { await alicePage.close(); }
});
test('create repository with invalid name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/new`, {
form: { name: 'has spaces!', description: '', default_branch: 'main' },
maxRedirects: 0,
});
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain('Invalid repository name');
});
});
Atests/e2e.settings.test.ts
@@ -0,0 +1,396 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import { rmSync, readFileSync } from 'node:fs';
import { spawnSync as nodeSpawnSync } from 'node:child_process';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
seedRepo,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Register alice
const regCtx = await browser.newContext();
const regPage = await regCtx.newPage();
try {
await regPage.goto(`${BASE}/register`);
await regPage.fill('[name=username]', 'alice');
await regPage.fill('[name=password]', 'password123');
await regPage.fill('[name=password2]', 'password123');
await regPage.click('button[type=submit]');
await regPage.waitForURL(BASE + '/');
} finally { await regCtx.close(); }
// Create my-repo (needed for repo deletion non-admin test)
const adminCtx = await browser.newContext();
const adminPage = await adminCtx.newPage();
try {
await login(adminPage);
await adminPage.goto(`${BASE}/new`);
await adminPage.fill('[name=name]', 'my-repo');
await adminPage.click('form[action="/new"] button[type=submit]');
await adminPage.waitForURL(`${BASE}/my-repo`);
} finally { await adminCtx.close(); }
await seedRepo('my-repo');
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Settings ─────────────────────────────────────────────────────────────────
describe('settings', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
// Public key generated once in beforeAll, reused across SSH key tests
let testPubKey: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Generate a throwaway ed25519 key for SSH key tests.
const keyPath = '/tmp/hf-e2e-sshkey';
rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true });
nodeSpawnSync(
'ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
{ stdio: 'ignore' },
);
testPubKey = readFileSync(`${keyPath}.pub`, 'utf-8').trim();
rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true });
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('settings page requires auth', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('settings page loads for logged-in user', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
} finally { await page.close(); }
});
// ── Password ──────────────────────────────────────────────────────────────
test('password change with mismatched passwords shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=new_password]', 'newpass123');
await page.fill('[name=confirm_password]', 'different456');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('password change with wrong current password shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=current_password]', 'wrongpassword');
await page.fill('[name=new_password]', 'newpass123');
await page.fill('[name=confirm_password]', 'newpass123');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('password change too short shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=current_password]', 'password123');
await page.fill('[name=new_password]', 'short');
await page.fill('[name=confirm_password]', 'short');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
// ── SSH keys ──────────────────────────────────────────────────────────────
test('add SSH key with unsupported key type shows error', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'Bad key');
await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('add valid SSH key shows success and key appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'My Laptop');
await page.fill('#ssh_public_key', testPubKey);
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=ssh_key_added');
await page.goto(`${BASE}/settings`);
expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
} finally { await page.close(); }
});
test('add duplicate SSH key shows error', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'Duplicate');
await page.fill('#ssh_public_key', testPubKey);
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('delete SSH key removes it from list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
// Click the Remove button for the key added above
await page.click('form[action="/settings/ssh-keys/delete"] button');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=ssh_key_deleted');
await page.goto(`${BASE}/settings`);
expect(await page.locator('.ssh-key-name').count()).toBe(0);
} finally { await page.close(); }
});
// ── Admin user management ────────────────────────────────────────────────
test('admin can create a new user account', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#new_username', 'charlie');
await page.fill('#new_user_password', 'charliepw1');
await page.click('form[action="/admin/users"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=user_created');
} finally { await page.close(); }
});
test('admin cannot create duplicate username', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#new_username', 'charlie');
await page.fill('#new_user_password', 'charliepw1');
await page.click('form[action="/admin/users"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('admin cannot create user with invalid username characters', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
form: { username: 'bad user!', password: 'password123' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location'] ?? '';
expect(location).toContain('error');
});
test('non-admin gets 403 when creating user', async () => {
const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
form: { username: 'hacker', password: 'password123' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
});
test('admin can delete user account', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
form: { username: 'charlie' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('success=user_deleted');
});
test('admin cannot delete the admin account', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
form: { username: 'admin' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('error');
});
test('settings page has no git identity section', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(await page.locator('text=Git Identity').count()).toBe(0);
expect(await page.locator('[name=git_name]').count()).toBe(0);
expect(await page.locator('[name=git_email]').count()).toBe(0);
} finally { await page.close(); }
});
test('git identity route no longer exists', async () => {
const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
form: { git_name: 'Test', git_email: 'test@example.com' },
maxRedirects: 0,
});
expect(resp.status()).toBe(404);
});
});
// ─── Repository deletion ──────────────────────────────────────────────────────
describe('repository deletion', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a repo to delete
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'deleteme-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/deleteme-repo`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('admin can delete repository', async () => {
const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toBe('/');
});
test('deleted repository returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/deleteme-repo`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('deleted repository no longer appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
} finally { await page.close(); }
});
test('non-admin cannot delete repository', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally {
await page.close();
await aliceCtx.close();
}
});
});
// ─── 404 handling ─────────────────────────────────────────────────────────────
describe('404 handling', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('non-existent repository returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/no-such-repo`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent issue returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent commit returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent file blob returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent patch returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent release returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
});
Atests/e2e.sorting.test.ts
@@ -0,0 +1,185 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
killServer,
login,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
await killServer(server);
});
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ─── Repo sorting and pinning ─────────────────────────────────────────────────
describe('repo sorting and pinning', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create two repos with predictable names: sort-aaa (created first/older),
// sort-zzz (created second/newer). This lets us verify both name order and
// creation-time order independently.
// Also create my-repo which is navigated to in one test.
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'my-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/my-repo`);
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'sort-aaa');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/sort-aaa`);
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'sort-zzz');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/sort-zzz`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('sort dropdown is visible on repo list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
const options = await page.locator('.repo-sort-select option').allTextContents();
expect(options.some(t => t.includes('Newest'))).toBe(true);
expect(options.some(t => t.includes('Name'))).toBe(true);
} finally { await page.close(); }
});
test('newest option is selected by default', async () => {
// Use a fresh context to ensure no repo_sort cookie is set.
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page, 'admin', ADMIN_PASS);
await page.goto(BASE);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('created');
} finally { await ctx.close(); }
});
test('Go button is hidden with JS and works without JS', async () => {
const ctx = await browser.newContext({ javaScriptEnabled: false });
const page = await ctx.newPage();
try {
await login(page, 'admin', ADMIN_PASS);
await page.goto(BASE);
// Go button visible without JS
expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true);
// Select name sort and submit via Go button
await page.locator('.repo-sort-select').selectOption('name');
await page.locator('form[action="/sort"] button[type=submit]').click();
await page.waitForURL(BASE + '/');
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
} finally { await ctx.close(); }
});
test('selecting name sort sets cookie and persists on next visit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await Promise.all([
page.waitForURL(BASE + '/'),
page.locator('.repo-sort-select').selectOption('name'),
]);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
// Navigate away and back to confirm cookie persists
await page.goto(`${BASE}/my-repo`);
await page.goto(BASE);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
} finally {
// Reset cookie so subsequent tests start from the default sort.
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.close();
}
});
test('default sort shows newer repo before older repo', async () => {
const page = await adminCtx.newPage();
try {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa'));
} finally { await page.close(); }
});
test('name sort shows repos in alphabetical order', async () => {
const page = await adminCtx.newPage();
try {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]);
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
} finally {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.close();
}
});
test('pinning a repo shows pinned badge on list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/sort-aaa/settings`);
await page.check('[name=is_pinned]');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
expect(await card.locator('.badge-pinned').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('pinned repo appears before unpinned repos regardless of creation order', async () => {
const page = await adminCtx.newPage();
try {
// sort-aaa is pinned; default (newest) sort would normally show sort-zzz
// first since it's newer — but pinned repos float to the top.
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
} finally { await page.close(); }
});
test('unpinning a repo removes the pinned badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/sort-aaa/settings`);
await page.uncheck('[name=is_pinned]');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
expect(await card.locator('.badge-pinned').count()).toBe(0);
} finally { await page.close(); }
});
});
Dtests/e2e.test.ts-3458
@@ -1,3458 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import { $ } from 'bun';
import { existsSync, readFileSync } from 'node:fs';
import {
BASE,
DATA_DIR,
ADMIN_PASS,
setupTestEnv,
spawnServer,
waitForServer,
login,
logout,
seedRepo,
seedBranch,
seedSubdir,
writeTempFile,
getHeadCommit,
PORT,
} from './helpers.ts';
// ─── Global setup ────────────────────────────────────────────────────────────
let browser: Browser;
let server: ReturnType<typeof spawnServer>;
beforeAll(async () => {
await setupTestEnv();
server = spawnServer();
await waitForServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
server.kill();
});
// Helper: create a context already logged in as a given user.
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// Helper: create N issues in a repo using the server API (no browser rendering)
async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) {
for (let i = 1; i <= count; i++) {
await ctx.request.fetch(`${BASE}/${repo}/issues`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `title=Issue+number+${i}&body=`,
maxRedirects: 0,
}).catch(() => {}); // 302 redirect throws; that's fine
}
}
// Helper: create N patches in a repo using the server API
async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
writeTempFile('/tmp/bulk.patch', VALID_PATCH);
for (let i = 1; i <= count; i++) {
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/${repo}/patches/new`);
await page.fill('[name=title]', `Patch number ${i}`);
await page.locator('[name=patch_file]').setInputFiles('/tmp/bulk.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(new RegExp(`/${repo}/patches/\\d+`));
} finally { await page.close(); }
}
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
describe('auth', () => {
test('homepage loads', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(BASE);
expect(await page.title()).toContain('Hearthforge');
} finally { await ctx.close(); }
});
test('wrong password shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/login`);
await page.fill('[name=username]', 'admin');
await page.fill('[name=password]', 'wrongpassword');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Invalid');
} finally { await ctx.close(); }
});
test('correct credentials redirect to homepage', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page);
expect(page.url()).toBe(BASE + '/');
expect(await page.locator('.nav-user').isVisible()).toBe(true);
} finally { await ctx.close(); }
});
test('register new user', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
await page.waitForURL(BASE + '/');
expect(await page.locator('.nav-user').textContent()).toBe('alice');
} finally { await ctx.close(); }
});
test('register with mismatched passwords shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'bob');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'different456');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('match');
} finally { await ctx.close(); }
});
test('register with duplicate username shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice'); // already registered above
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('taken');
} finally { await ctx.close(); }
});
test('logout clears session', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page);
await logout(page);
expect(await page.locator('.nav-user').count()).toBe(0);
expect(await page.locator('a[href="/login"]').isVisible()).toBe(true);
} finally { await ctx.close(); }
});
});
// ─── Repos ────────────────────────────────────────────────────────────────────
describe('repos', () => {
// Shared admin context — cookies persist across tests in this block.
let adminCtx: BrowserContext;
// Alice's context, created after alice is registered in auth tests.
let aliceCtx: BrowserContext;
// Set after 'commit log' test; used by all commit-detail tests below.
let commitUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('non-admin gets 403 on /new', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/new`);
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('create repository', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'my-repo');
await page.fill('[name=description]', 'A test repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/my-repo`);
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('repository appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
} finally { await page.close(); }
});
test('search finds matching repo', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await page.fill('[name=q]', 'my-repo');
await page.click('.search-form button[type=submit]');
expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
} finally { await page.close(); }
});
test('search returns empty for unknown term', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await page.fill('[name=q]', 'zzz-nothing-here');
await page.click('.search-form button[type=submit]');
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('browse file tree after seeding content', async () => {
await seedRepo('my-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
const files = await page.locator('.file-name a').allTextContents();
expect(files).toContain('README.md');
expect(files).toContain('index.js');
} finally { await page.close(); }
});
test('view file blob with syntax highlighting', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/blob/main/index.js`);
expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('raw file download responds 200', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
expect(resp.status()).toBe(200);
expect(resp.headers()['content-disposition']).toContain('README.md');
} finally { await page.close(); }
});
test('commit log shows initial commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
const subjects = await page.locator('.commit-subject').allTextContents();
expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
// Navigate to the commit page and capture the URL for subsequent tests
await page.locator('.commit-hash').first().click();
await page.waitForURL(/\/my-repo\/commit\//);
commitUrl = page.url();
} finally { await page.close(); }
});
test('commit detail shows metadata card', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
expect(await page.locator('.commit-card').isVisible()).toBe(true);
expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
// Author, date and SHA rows are all present
const metaText = await page.locator('.commit-card-meta').textContent();
expect(metaText).toContain('Test'); // author name set by seedRepo
expect(metaText).toContain('Author'); // label (CSS uppercases visually)
expect(metaText).toContain('Date');
expect(metaText).toContain('Commit');
} finally { await page.close(); }
});
test('commit detail full SHA is shown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const sha = commitUrl.split('/commit/')[1] ?? null;
expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
} finally { await page.close(); }
});
test('commit detail shows file nav sidebar', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
const navItems = await page.locator('.file-nav-item').allTextContents();
// seedRepo adds README.md and index.js
expect(navItems.some(t => t.includes('README.md'))).toBe(true);
expect(navItems.some(t => t.includes('index.js'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail file nav items are anchor links to diff sections', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const hrefs = await page.locator('.file-nav-item').evaluateAll(
els => els.map(el => el.getAttribute('href') ?? ''),
);
expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail shows diff table with added lines', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// Initial commit only adds lines
expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
expect(await page.locator('.diff-row-del').count()).toBe(0);
} finally { await page.close(); }
});
test('commit detail diff table has line numbers', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// New-side line numbers (column 2) on add rows start at 1
const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
expect(firstNewLn?.trim()).toBe('1');
} finally { await page.close(); }
});
test('commit detail shows added stats on file header', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const addStats = await page.locator('.diff-stat-add').allTextContents();
expect(addStats.length).toBeGreaterThan(0);
expect(addStats.every(s => s.startsWith('+'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail view-at-sha button links to blob at that commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const sha = commitUrl.split('/commit/')[1];
const btn = page.locator('.btn-xs').first();
const href = await btn.getAttribute('href');
expect(href).toContain(`/blob/${sha}/`);
} finally { await page.close(); }
});
test('commit detail view-at-branch button links to blob at default branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const btns = await page.locator('.btn-xs').allTextContents();
expect(btns.some(t => t.includes('@ main'))).toBe(true);
const branchBtns = await page.locator('.btn-xs').evaluateAll(
els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
);
expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail file diff can be collapsed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// File body is visible when details is open
const diffFile = page.locator('.diff-file').first();
expect(await diffFile.getAttribute('open')).not.toBeNull();
// Click the summary to collapse
await diffFile.locator('.diff-file-header').click();
expect(await diffFile.getAttribute('open')).toBeNull();
} finally { await page.close(); }
});
test('commit detail file nav sidebar can be collapsed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const nav = page.locator('.file-nav-details');
expect(await nav.getAttribute('open')).not.toBeNull();
await nav.locator('.file-nav-toggle').click();
expect(await nav.getAttribute('open')).toBeNull();
} finally { await page.close(); }
});
test('readme renders on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.readme-header').isVisible()).toBe(true);
expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
} finally { await page.close(); }
});
test('private repo hidden from other users', async () => {
// Make private
const adminPage = await adminCtx.newPage();
try {
await adminPage.goto(`${BASE}/my-repo/settings`);
await adminPage.check('[name=is_private]');
await adminPage.click('form[action$="/settings"] button[type=submit]');
expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
} finally { await adminPage.close(); }
// Alice should get 404
const alicePage = await aliceCtx.newPage();
try {
const resp = await alicePage.request.get(`${BASE}/my-repo`);
expect(resp.status()).toBe(404);
await alicePage.goto(BASE);
expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
} finally { await alicePage.close(); }
// Restore to public
const adminPage2 = await adminCtx.newPage();
try {
await adminPage2.goto(`${BASE}/my-repo/settings`);
await adminPage2.uncheck('[name=is_private]');
await adminPage2.click('form[action$="/settings"] button[type=submit]');
} finally { await adminPage2.close(); }
});
test('settings tab visible for admin, hidden for others', async () => {
const adminPage = await adminCtx.newPage();
try {
await adminPage.goto(`${BASE}/my-repo`);
expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
} finally { await adminPage.close(); }
const alicePage = await aliceCtx.newPage();
try {
await alicePage.goto(`${BASE}/my-repo`);
expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
} finally { await alicePage.close(); }
});
test('create repository with invalid name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/new`, {
form: { name: 'has spaces!', description: '', default_branch: 'main' },
maxRedirects: 0,
});
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain('Invalid repository name');
});
});
// ─── Issues ───────────────────────────────────────────────────────────────────
describe('issues', () => {
let adminCtx: BrowserContext;
let issueUrl: string;
let completedIssueUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
});
afterAll(async () => { await adminCtx.close(); });
test('create issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'First issue');
await page.fill('[name=body]', 'Body with **markdown**.');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
issueUrl = page.url();
expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
} finally { await page.close(); }
});
test('issue body renders markdown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
} finally { await page.close(); }
});
test('issue appears in open list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('First issue'))).toBe(true);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from new issue form', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('add comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const beforeCount = await page.locator('.timeline-item').count();
await page.fill('textarea[name=body]', 'A follow-up comment.');
await page.click('form[action*="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
} finally { await page.close(); }
});
test('react to issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.locator('.reaction-picker').first().click();
await page.locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('close issue changes status badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('closed');
} finally { await page.close(); }
});
test('closed issue appears in closed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues?status=closed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('First issue'))).toBe(true);
} finally { await page.close(); }
});
test('reopen issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('.issue-detail-meta-actions button');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('open');
} finally { await page.close(); }
});
test('completed button marks issue as completed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'To be completed');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
completedIssueUrl = page.url();
await page.click('form[action*="/complete"] button');
await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('completed');
} finally { await page.close(); }
});
test('completed issue appears in completed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues?status=completed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('To be completed'))).toBe(true);
} finally { await page.close(); }
});
test('non-admin cannot complete or close issue', async () => {
const issueNum = issueUrl.split('/issues/')[1];
const ctx = await browser.newContext();
try {
const completeResp = await ctx.request.post(
`${BASE}/my-repo/issues/${issueNum}/complete`,
{ maxRedirects: 0 },
);
expect(completeResp.status()).toBe(302);
expect(completeResp.headers()['location']).toContain('/login');
} finally { await ctx.close(); }
});
test('reacting with same emoji toggles it off', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Reaction was added by the earlier 'react to issue' test
expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
await page.locator('.reaction-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').count()).toBe(0);
} finally { await page.close(); }
});
test('react to issue comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)')
.filter({ hasText: 'A follow-up comment.' });
await commentItem.locator('.reaction-add-btn').click();
await commentItem.locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
});
// ─── Patches ──────────────────────────────────────────────────────────────────
describe('patches', () => {
let adminCtx: BrowserContext;
let cleanPatchUrl: string;
let conflictPatchUrl: string;
let closePatchUrl: string;
// Adds a new file — applies cleanly to my-repo
const CLEAN_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add patch-test.txt',
'',
'---',
'diff --git a/patch-test.txt b/patch-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/patch-test.txt',
'@@ -0,0 +1 @@',
'+patch test content',
'',
].join('\n');
// References non-existent lines in README.md — always conflicts
const CONFLICT_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Modify README',
'',
'---',
'diff --git a/README.md b/README.md',
'index abc1234..def5678 100644',
'--- a/README.md',
'+++ b/README.md',
'@@ -50,3 +50,3 @@',
' nonexistent context line',
'-nonexistent old line',
'+nonexistent new line',
'',
].join('\n');
// Adds another new file — for testing close flow
const CLOSE_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add patch-close.txt',
'',
'---',
'diff --git a/patch-close.txt b/patch-close.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/patch-close.txt',
'@@ -0,0 +1 @@',
'+close test',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
});
afterAll(async () => { await adminCtx.close(); });
test('reject file without patch markers', async () => {
writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Bad patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('valid patch');
} finally { await page.close(); }
});
test('reject patch missing Subject header', async () => {
writeTempFile('/tmp/no-subject.patch', [
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No subject');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Subject');
} finally { await page.close(); }
});
test('reject patch missing From header', async () => {
writeTempFile('/tmp/no-from.patch', [
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No from');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('From');
} finally { await page.close(); }
});
test('reject patch missing Date header', async () => {
writeTempFile('/tmp/no-date.patch', [
'From: Test User <test@example.com>',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No date');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Date');
} finally { await page.close(); }
});
test('upload clean patch', async () => {
writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Add patch-test.txt');
await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
cleanPatchUrl = page.url();
expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
} finally { await page.close(); }
});
test('changes tab shows commit metadata card', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl + '?tab=changes');
expect(await page.locator('.commit-card').isVisible()).toBe(true);
expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch description renders markdown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
} finally { await page.close(); }
});
test('clean patch shows apply-clean status immediately', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.apply-result').isVisible()).toBe(true);
expect(await page.locator('.apply-clean').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('merge button appears for clean patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch diff is displayed with highlighted table', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl + '?tab=changes');
expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('patch appears in open list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from patch upload', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('upload conflict patch', async () => {
writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Conflict patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
conflictPatchUrl = page.url();
} finally { await page.close(); }
});
test('conflict patch shows apply-conflict status', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('merge button absent for conflict patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
} finally { await page.close(); }
});
test('merge clean patch changes status to merged', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
await page.click('form[action*="/merge"] button');
await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('merged');
} finally { await page.close(); }
});
test('merge uses patch From header as git author', async () => {
const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
const authorEmail = (await $`git -C ${repoPath} log -1 --format=%aE`.quiet()).text().trim();
const subject = (await $`git -C ${repoPath} log -1 --format=%s`.quiet()).text().trim();
expect(authorName).toBe('Test User');
expect(authorEmail).toBe('test@example.com');
expect(subject).toBe('Add patch-test.txt');
});
test('merged patch appears in merged list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches?status=merged`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
} finally { await page.close(); }
});
test('upload and close a patch', async () => {
writeTempFile('/tmp/close.patch', CLOSE_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Close me');
await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
closePatchUrl = page.url();
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('closed');
} finally { await page.close(); }
});
test('closed patch appears in closed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches?status=closed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Close me'))).toBe(true);
} finally { await page.close(); }
});
test('closed patch can be reopened', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(closePatchUrl);
expect(await page.locator('.patch-badge').textContent()).toBe('closed');
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('open');
} finally { await page.close(); }
});
test('patch title and description can be edited', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
// Edit title via title form
await page.click('details.title-edit-details summary');
await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
await page.click('.title-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
// Edit description via inline form
await page.click('.timeline-author .inline-edit-details summary');
await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
await page.click('.inline-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('patch comment: add and edit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
await page.fill('[name=body]', 'My patch comment');
await page.click('form[action$="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
// Edit the comment — scope to the timeline-item containing the comment text
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
await commentItem.locator('.inline-edit-details summary').click();
await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
await commentItem.locator('.inline-edit-form-area [type=submit]').click();
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
} finally { await page.close(); }
});
test('patch reaction on description', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
// Open reaction picker on the first timeline-item (description)
await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
} finally { await page.close(); }
});
test('admin can delete a patch', async () => {
const page = await adminCtx.newPage();
try {
const patchNum = conflictPatchUrl.split('/patches/')[1];
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('/patches');
// Patch should no longer be accessible
const checkResp = await page.request.get(conflictPatchUrl);
expect(checkResp.status()).toBe(404);
} finally { await page.close(); }
});
// ── Patch file re-upload & version protection ──────────────────────────────
let uploadTestPatchUrl: string;
// Adds upload-test.txt — applies cleanly to my-repo
const UPLOAD_TEST_PATCH = [
'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001',
'From: Original Author <original@example.com>',
'Date: Wed, 03 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add upload-test.txt',
'',
'---',
'diff --git a/upload-test.txt b/upload-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/upload-test.txt',
'@@ -0,0 +1 @@',
'+upload test',
'',
].join('\n');
// Replacement: different author, same diff target
const REPLACEMENT_PATCH = [
'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001',
'From: Replaced Author <replaced@example.com>',
'Date: Thu, 04 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add upload-test.txt (v2)',
'',
'---',
'diff --git a/upload-test.txt b/upload-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/upload-test.txt',
'@@ -0,0 +1 @@',
'+upload test v2',
'',
].join('\n');
test('create patch for re-upload tests', async () => {
writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Upload test patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
uploadTestPatchUrl = page.url();
} finally { await page.close(); }
});
test('upload patch file button is visible for admin on open patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(1);
} finally { await page.close(); }
});
test('non-author non-admin cannot upload patch file', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
const patchNum = uploadTestPatchUrl.split('/patches/')[1];
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await aliceCtx.close(); }
});
test('upload button hidden for non-author non-admin', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
} finally { await aliceCtx.close(); }
});
test('admin can upload replacement patch file', async () => {
writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
await page.locator('details:has([name=patch_file]) summary').click();
await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch');
await page.locator('details:has([name=patch_file]) button[type=submit]').click();
await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('merge fails when version token is stale', async () => {
// Patch that adds stale-version.txt — applies cleanly
const STALE_PATCH = [
'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Fri, 05 Jan 2024 10:00:00 +0000',
'Subject: [PATCH] Add stale-version.txt',
'',
'---',
'diff --git a/stale-version.txt b/stale-version.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/stale-version.txt',
'@@ -0,0 +1 @@',
'+stale',
'',
].join('\n');
const STALE_PATCH_V2 = STALE_PATCH
.replace('Add stale-version.txt', 'Add stale-version.txt (v2)')
.replace('+stale', '+stale v2');
writeTempFile('/tmp/stale.patch', STALE_PATCH);
const page = await adminCtx.newPage();
try {
// Create the patch
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Stale version test');
await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
const stalePatchUrl = page.url();
const patchNum = stalePatchUrl.split('/patches/')[1];
// Capture the version the admin sees on the page
const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue();
// Author uploads a new patch file (simulated by admin here), bumping the version
writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2);
await page.locator('details:has([name=patch_file]) summary').click();
await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch');
await page.locator('details:has([name=patch_file]) button[type=submit]').click();
await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, '')));
// Admin tries to merge with the stale version — should be rejected
const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, {
form: { version: staleVersion },
maxRedirects: 0,
});
expect(resp.status()).toBe(409);
expect(await resp.text()).toContain('updated');
// Patch status must still be open
const checkResp = await page.request.get(stalePatchUrl);
expect(checkResp.status()).toBe(200);
expect(await checkResp.text()).toContain('open');
} finally { await page.close(); }
});
test('merge succeeds with current version token after replacement upload', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
await page.click('form[action*="/merge"] button');
await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('merged');
// Merged commit should carry the replacement patch's author
const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
expect(authorName).toBe('Replaced Author');
} finally { await page.close(); }
});
test('upload patch file button hidden on merged patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(uploadTestPatchUrl);
expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
} finally { await page.close(); }
});
test('POST to upload on merged patch returns 400', async () => {
const patchNum = uploadTestPatchUrl.split('/patches/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
maxRedirects: 0,
});
expect(resp.status()).toBe(400);
});
});
// ─── Pagination ───────────────────────────────────────────────────────────────
describe('pagination', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a repo dedicated to pagination testing
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'paged-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/paged-repo`);
} finally { await page.close(); }
// Create 21 issues via the API (triggers page 2 at 20 per page)
await bulkCreateIssues(adminCtx, 'paged-repo', 21);
// Create 21 patches via browser (patch upload requires multipart)
await bulkCreatePatches(adminCtx, 'paged-repo', 21);
});
afterAll(async () => { await adminCtx.close(); });
// ── Repo list pagination ──────────────────────────────────────────────────
test('repo list page 1 shows repos and no pagination when few repos', async () => {
// With only a handful of test repos (< 20), there should be no pagination nav
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
// Repos are shown
expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
// ── Issue pagination ──────────────────────────────────────────────────────
test('issue list page 1 shows at most 20 items', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('issue list pagination nav appears when more than 20 issues', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('issue list page 2 shows remaining issues', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('issue list page 2 prev link goes to page 1', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues?page=2`);
const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
expect(prevHref).toContain('page=1');
} finally { await page.close(); }
});
test('issue list page 1 next link goes to page 2', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
expect(nextHref).toContain('page=2');
} finally { await page.close(); }
});
// ── Patch pagination ──────────────────────────────────────────────────────
test('patch list page 1 shows at most 20 items', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('patch list pagination nav appears when more than 20 patches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch list page 2 shows remaining patches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
} finally { await page.close(); }
});
// ── Commit log pagination ─────────────────────────────────────────────────
test('commit log with few commits shows no cursor nav', async () => {
// my-repo has 1 commit — both newer and older links should be absent
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
} finally { await page.close(); }
});
});
// ─── Branch selector ──────────────────────────────────────────────────────────
describe('branch selector', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Add a second branch so the selector is meaningful
await seedBranch('my-repo', 'dev');
});
afterAll(async () => { await adminCtx.close(); });
test('branch selector appears on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.branch-selector').isVisible()).toBe(true);
expect(await page.locator('.branch-select').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('branch selector shows all branches on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const options = await page.locator('.branch-select option').allTextContents();
expect(options).toContain('main');
expect(options).toContain('dev');
} finally { await page.close(); }
});
test('branch selector appears on file tree with current ref selected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
expect(await page.locator('.branch-selector').isVisible()).toBe(true);
expect(await page.locator('.branch-select').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('branch selector appears on commit log with current ref selected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
expect(await page.locator('.branch-selector').isVisible()).toBe(true);
expect(await page.locator('.branch-select').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('branch selector appears on file blob', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/blob/main/README.md`);
expect(await page.locator('.branch-selector').isVisible()).toBe(true);
expect(await page.locator('.branch-select').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('switching branch on commit log navigates to the selected branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
await page.locator('.branch-select').selectOption('dev');
await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
await page.waitForURL(`${BASE}/my-repo/commits/dev`);
expect(page.url()).toContain('/commits/dev');
} finally { await page.close(); }
});
test('switching branch on file tree navigates to the selected branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
await page.locator('.branch-select').selectOption('dev');
await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
await page.waitForURL(`${BASE}/my-repo/tree/dev`);
expect(page.url()).toContain('/tree/dev');
} finally { await page.close(); }
});
test('branch-switch route preserves subpath when switching tree', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(
`${BASE}/my-repo/branch-switch?view=tree&rev=dev&path=src/foo`,
{ maxRedirects: 0 },
).catch(r => r);
// 302 redirect to /my-repo/tree/dev/src/foo
const loc = (resp as any).headers()?.['location'] ?? '';
expect(loc).toContain('/tree/dev/src/foo');
} finally { await page.close(); }
});
test('branch-switch route redirects commits view correctly', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(
`${BASE}/my-repo/branch-switch?view=commits&rev=dev`,
{ maxRedirects: 0 },
).catch(r => r);
const loc = (resp as any).headers()?.['location'] ?? '';
expect(loc).toContain('/commits/dev');
} finally { await page.close(); }
});
test('branch-switch route redirects blob view correctly', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(
`${BASE}/my-repo/branch-switch?view=blob&rev=dev&path=README.md`,
{ maxRedirects: 0 },
).catch(r => r);
const loc = (resp as any).headers()?.['location'] ?? '';
expect(loc).toContain('/blob/dev/README.md');
} finally { await page.close(); }
});
});
// ─── Default branch settings ──────────────────────────────────────────────────
describe('default branch settings', () => {
// Runs after 'branch selector', so my-repo already has both main and dev branches.
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('settings page shows default branch select', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
expect(await page.locator('#default_branch').isVisible()).toBe(true);
const options = await page.locator('#default_branch option').allTextContents();
expect(options).toContain('main');
expect(options).toContain('dev');
} finally { await page.close(); }
});
test('current default branch is pre-selected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
expect(await page.locator('#default_branch').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('changing default branch saves and is reflected in the repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.locator('#default_branch').selectOption('dev');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
// The select now shows dev as current
expect(await page.locator('#default_branch').inputValue()).toBe('dev');
// Repo home branch selector should reflect the new default
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.branch-select').inputValue()).toBe('dev');
} finally { await page.close(); }
});
test('commit log link in repo nav uses the new default branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href');
expect(commitsHref).toContain('/commits/dev');
} finally { await page.close(); }
});
test('changing default branch back to main restores original state', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.locator('#default_branch').selectOption('main');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
expect(await page.locator('#default_branch').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('settings page shows hint instead of select when repo has no branches', async () => {
// Create an empty repo (no commits → no branches)
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'empty-for-branch-test');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/empty-for-branch-test`);
await page.goto(`${BASE}/empty-for-branch-test/settings`);
expect(await page.locator('#default_branch').count()).toBe(0);
expect(await page.locator('.form-hint').isVisible()).toBe(true);
} finally { await page.close(); }
});
});
// ─── File browser ─────────────────────────────────────────────────────────────
describe('file browser', () => {
// my-repo already has README.md + index.js from the 'repos' describe block.
// We add a subdirectory here so we can test directory navigation.
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
await seedSubdir('my-repo', 'src', {
'app.ts': 'export {};\n',
'README.md': '# src readme\n',
});
});
afterAll(async () => { await adminCtx.close(); });
test('repo home shows file tree instead of recent commits', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.file-tree').isVisible()).toBe(true);
expect(await page.locator('.repo-commits-section').count()).toBe(0);
} finally { await page.close(); }
});
test('repo home file tree lists files and directories', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const names = await page.locator('.file-name a').allTextContents();
expect(names).toContain('README.md');
expect(names).toContain('index.js');
expect(names).toContain('src');
} finally { await page.close(); }
});
test('directories appear before files in file tree', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const names = await page.locator('.file-name a').allTextContents();
const srcIdx = names.indexOf('src');
const readmeIdx = names.indexOf('README.md');
expect(srcIdx).toBeGreaterThanOrEqual(0);
expect(readmeIdx).toBeGreaterThanOrEqual(0);
expect(srcIdx).toBeLessThan(readmeIdx);
} finally { await page.close(); }
});
test('no ".." entry at repository root', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const names = await page.locator('.file-name a').allTextContents();
expect(names).not.toContain('..');
} finally { await page.close(); }
});
test('clicking directory navigates into it', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
await page.locator('.file-name a', { hasText: 'src' }).click();
await page.waitForURL(`${BASE}/my-repo/tree/main/src`);
expect(page.url()).toContain('/tree/main/src');
} finally { await page.close(); }
});
test('".." entry appears in subdirectory', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main/src`);
const names = await page.locator('.file-name a').allTextContents();
expect(names).toContain('..');
} finally { await page.close(); }
});
test('".." at one level deep links to tree root', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main/src`);
const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href');
expect(upHref).toBe('/my-repo/tree/main');
} finally { await page.close(); }
});
test('files in subdirectory show plain names, not full paths', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main/src`);
const names = await page.locator('.file-name a').allTextContents();
expect(names).toContain('app.ts');
// Must NOT contain the full path with prefix
expect(names).not.toContain('src/app.ts');
expect(names).not.toContain('src/README.md');
} finally { await page.close(); }
});
test('readme is shown below file tree on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const treeBox = await page.locator('.file-tree').boundingBox();
const readmeBox = await page.locator('.readme-section').boundingBox();
expect(treeBox).not.toBeNull();
expect(readmeBox).not.toBeNull();
expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1);
} finally { await page.close(); }
});
test('readme in subdirectory is shown when present', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main/src`);
expect(await page.locator('.readme-section').isVisible()).toBe(true);
expect(await page.locator('.readme-section .markdown-body').innerHTML())
.toContain('src readme');
} finally { await page.close(); }
});
test('file tree on /tree/:ref also shows readme', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
expect(await page.locator('.file-tree').isVisible()).toBe(true);
expect(await page.locator('.readme-section').isVisible()).toBe(true);
} finally { await page.close(); }
});
});
// ─── Releases ─────────────────────────────────────────────────────────────────
describe('releases', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let releaseUrl: string;
let srcReleaseUrl: string;
let releaseWithAssetsUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create a dedicated repo with at least one commit
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'releases-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/releases-repo`);
} finally { await page.close(); }
await seedRepo('releases-repo');
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
// ── Navigation ──────────────────────────────────────────────────────────────
test('releases tab visible in repo nav', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo`);
expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
} finally { await page.close(); }
});
test('releases list shows empty state when no releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Access control ──────────────────────────────────────────────────────────
test('non-admin cannot access /releases/new', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('non-admin POST to /releases returns 403', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Test', tag_name: 'v0.1.0' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from /releases/new', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
// ── Validation ──────────────────────────────────────────────────────────────
test('missing release title shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {},
});
expect(await resp.text()).toContain('Release title is required');
});
test('create_tag checked but no tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Test', create_tag: 'on' },
});
expect(await resp.text()).toContain('Tag name is required');
});
// ── Create ──────────────────────────────────────────────────────────────────
test('create a basic release', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
create_tag: 'on',
tag_name: 'v1.0.0',
revision: 'main',
name: 'First release',
notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
},
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
releaseUrl = `${BASE}${location}`;
});
test('duplicate tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' },
});
expect(await resp.text()).toContain('already exists');
});
// ── List ────────────────────────────────────────────────────────────────────
test('release appears in list with tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.release-item-title').textContent()).toContain('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('release list shows tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('new release button hidden for non-admin', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
} finally { await page.close(); }
});
// ── Detail ──────────────────────────────────────────────────────────────────
test('release detail shows title and tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('h2.page-title').textContent()).toBe('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('release notes rendered in detail view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
} finally { await page.close(); }
});
// ── Source archives ─────────────────────────────────────────────────────────
test('create release with source code archives', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
srcReleaseUrl = `${BASE}${location}`;
});
test('zip and tar.gz archives appear in downloads', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const assetNames = await page.locator('.asset-name').allTextContents();
expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
} finally { await page.close(); }
});
test('source archive download responds with 200', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
const resp = await page.request.get(`${BASE}${zipLink}`);
expect(resp.status()).toBe(200);
} finally { await page.close(); }
});
// ── File upload ─────────────────────────────────────────────────────────────
test('create release with attached file', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
name: 'Asset release',
create_tag: 'on',
tag_name: 'v1.2.0',
revision: 'main',
files: {
name: 'release-asset.txt',
mimeType: 'text/plain',
buffer: Buffer.from('binary-like content for testing\n'),
},
},
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
releaseWithAssetsUrl = `${BASE}${location}`;
});
test('uploaded asset appears in downloads with filename and size', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseWithAssetsUrl);
const names = await page.locator('.asset-name').allTextContents();
expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
expect(await page.locator('.asset-size').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('asset download responds with 200', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseWithAssetsUrl);
const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
const resp = await page.request.get(`${BASE}${link}`);
expect(resp.status()).toBe(200);
} finally { await page.close(); }
});
// ── Delete ──────────────────────────────────────────────────────────────────
test('non-admin cannot delete a release', async () => {
const page = await aliceCtx.newPage();
try {
const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('admin can delete a release', async () => {
const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
const resp = await adminCtx.request.post(
`${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
{ maxRedirects: 0 },
);
expect(resp.status()).toBe(302);
// Verify it's gone from the list
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
const titles = await page.locator('.release-item-title').allTextContents();
expect(titles.some(t => t.includes('First release'))).toBe(false);
} finally { await page.close(); }
});
// ── Pagination ──────────────────────────────────────────────────────────────
test('release list shows at most 20 per page', async () => {
// Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
// regardless of which browser-based tests above succeeded
for (let i = 1; i <= 25; i++) {
await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' },
maxRedirects: 0,
}).catch(() => {});
}
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('pagination nav appears with more than 20 releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('release list page 2 shows remaining releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('source archive tar.zst appears in downloads', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const assetNames = await page.locator('.asset-name').allTextContents();
expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
} finally { await page.close(); }
});
});
// ─── Settings ─────────────────────────────────────────────────────────────────
describe('settings', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
// Public key generated once in beforeAll, reused across SSH key tests
let testPubKey: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Generate a throwaway ed25519 key for SSH key tests.
// Use Bun.spawn so the empty passphrase arg is passed correctly.
const keyPath = '/tmp/hf-e2e-sshkey';
await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
const keygen = Bun.spawn(
['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
{ stdout: 'ignore', stderr: 'ignore' },
);
await keygen.exited;
testPubKey = await Bun.file(`${keyPath}.pub`).text();
testPubKey = testPubKey.trim();
await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('settings page requires auth', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('settings page loads for logged-in user', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
} finally { await page.close(); }
});
// ── Password ──────────────────────────────────────────────────────────────
test('password change with mismatched passwords shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=new_password]', 'newpass123');
await page.fill('[name=confirm_password]', 'different456');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('password change with wrong current password shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=current_password]', 'wrongpassword');
await page.fill('[name=new_password]', 'newpass123');
await page.fill('[name=confirm_password]', 'newpass123');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('password change too short shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=current_password]', 'password123');
await page.fill('[name=new_password]', 'short');
await page.fill('[name=confirm_password]', 'short');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
// ── SSH keys ──────────────────────────────────────────────────────────────
test('add SSH key with unsupported key type shows error', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'Bad key');
await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('add valid SSH key shows success and key appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'My Laptop');
await page.fill('#ssh_public_key', testPubKey);
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=ssh_key_added');
await page.goto(`${BASE}/settings`);
expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
} finally { await page.close(); }
});
test('add duplicate SSH key shows error', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'Duplicate');
await page.fill('#ssh_public_key', testPubKey);
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('delete SSH key removes it from list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
// Click the Remove button for the key added above
await page.click('form[action="/settings/ssh-keys/delete"] button');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=ssh_key_deleted');
await page.goto(`${BASE}/settings`);
expect(await page.locator('.ssh-key-name').count()).toBe(0);
} finally { await page.close(); }
});
// ── Admin user management ────────────────────────────────────────────────
test('admin can create a new user account', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#new_username', 'charlie');
await page.fill('#new_user_password', 'charliepw1');
await page.click('form[action="/admin/users"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=user_created');
} finally { await page.close(); }
});
test('admin cannot create duplicate username', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#new_username', 'charlie');
await page.fill('#new_user_password', 'charliepw1');
await page.click('form[action="/admin/users"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('admin cannot create user with invalid username characters', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
form: { username: 'bad user!', password: 'password123' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location'] ?? '';
expect(location).toContain('error');
});
test('non-admin gets 403 when creating user', async () => {
const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
form: { username: 'hacker', password: 'password123' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
});
test('admin can delete user account', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
form: { username: 'charlie' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('success=user_deleted');
});
test('admin cannot delete the admin account', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
form: { username: 'admin' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('error');
});
test('settings page has no git identity section', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(await page.locator('text=Git Identity').count()).toBe(0);
expect(await page.locator('[name=git_name]').count()).toBe(0);
expect(await page.locator('[name=git_email]').count()).toBe(0);
} finally { await page.close(); }
});
test('git identity route no longer exists', async () => {
const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
form: { git_name: 'Test', git_email: 'test@example.com' },
maxRedirects: 0,
});
expect(resp.status()).toBe(404);
});
});
// ─── Repository deletion ──────────────────────────────────────────────────────
describe('repository deletion', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a repo to delete
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'deleteme-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/deleteme-repo`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('admin can delete repository', async () => {
const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toBe('/');
});
test('deleted repository returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/deleteme-repo`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('deleted repository no longer appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
} finally { await page.close(); }
});
test('non-admin cannot delete repository', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally {
await page.close();
await aliceCtx.close();
}
});
});
// ─── 404 handling ─────────────────────────────────────────────────────────────
describe('404 handling', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('non-existent repository returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/no-such-repo`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent issue returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent commit returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent file blob returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent patch returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent release returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
});
// ─── Issue editing and deletion ───────────────────────────────────────────────
describe('issue editing', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let issueUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create an issue to edit
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'Issue to edit');
await page.fill('[name=body]', 'Original body.');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
issueUrl = page.url();
} finally { await page.close(); }
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('author can edit issue title and body', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Edit title via title form
await page.click('details.title-edit-details summary');
await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
await page.click('.title-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
// Edit body via inline form
await page.click('.timeline-author .inline-edit-details summary');
await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
await page.click('.inline-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
} finally { await page.close(); }
});
test('edited marker appears after editing', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('non-author non-admin cannot edit issue', async () => {
const page = await aliceCtx.newPage();
try {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
form: { title: 'Hacked title', edit_body: '' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('author can edit issue comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Add a comment first
await page.fill('textarea[name=body]', 'Comment to edit.');
await page.click('form[action*="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
// Edit the comment
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
await commentItem.locator('.inline-edit-details summary').click();
await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
await commentItem.locator('.inline-edit-form-area [type=submit]').click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
} finally { await page.close(); }
});
test('non-admin user can create an issue', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', "Alice's issue");
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
} finally { await page.close(); }
});
test('non-admin cannot comment on a closed issue', async () => {
// Close the issue as admin first
const issueNum = issueUrl.split('/issues/')[1];
await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
form: { body: 'comment on closed issue' },
maxRedirects: 0,
});
// Non-admin gets redirected (silently ignored), not an error
expect(resp.status()).toBe(302);
// The comment should NOT appear
await page.goto(issueUrl);
const bodies = await page.locator('.timeline-body').allTextContents();
expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
} finally { await page.close(); }
});
test('admin can delete issue', async () => {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
// Issue should be gone
const page = await adminCtx.newPage();
try {
const checkResp = await page.request.get(issueUrl);
expect(checkResp.status()).toBe(404);
} finally { await page.close(); }
});
});
// ─── Repository description update ───────────────────────────────────────────
describe('repo description', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('updating repo description is reflected on list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=description]', 'A freshly updated description');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const desc = await page.locator('.repo-description').allTextContents();
expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
} finally { await page.close(); }
});
});
// ─── Issue and patch templates ────────────────────────────────────────────────
describe('issue and patch templates', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('issue template can be saved and is prefilled on new issue form', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/issues/new`);
const body = await page.locator('[name=body]').inputValue();
expect(body).toContain('## Steps to reproduce');
expect(body).toContain('## Expected behavior');
} finally { await page.close(); }
});
test('patch template can be saved and is prefilled on new patch form', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/patches/new`);
const desc = await page.locator('[name=description]').inputValue();
expect(desc).toContain('## Summary');
expect(desc).toContain('## Testing');
} finally { await page.close(); }
});
test('clearing the issue template removes prefill', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=issue_template]', '');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/issues/new`);
const body = await page.locator('[name=body]').inputValue();
expect(body).toBe('');
} finally { await page.close(); }
});
test('clearing the patch template removes prefill', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=patch_template]', '');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/patches/new`);
const desc = await page.locator('[name=description]').inputValue();
expect(desc).toBe('');
} finally { await page.close(); }
});
});
// ─── Commit signing ───────────────────────────────────────────────────────────
// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
describe('commit signing', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('allowed_signers file is generated at startup', () => {
const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
expect(existsSync(allowedSignersPath)).toBe(true);
const content = readFileSync(allowedSignersPath, 'utf8');
expect(content).toContain('namespaces="git"');
expect(content).toContain('ssh-ed25519');
});
test('merged commit has a gpgsig header', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
// Find the patch commit specifically by subject
const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
expect(hash).toBeTruthy();
const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
expect(obj).toContain('gpgsig');
});
test('unsigned commits have no gpgsig header', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
// Initial commit was created by seedRepo (plain git commit, not hearthforge)
const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
expect(hash).toBeTruthy();
const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
expect(obj).not.toContain('gpgsig');
});
test('commit log shows verified badge on signed commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
// Find the commit-item for the merged patch by subject text
const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('commit log shows no sig badge on unsigned commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
// Initial commit was not signed via hearthforge
const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
expect(await initialItem.locator('.sig-badge').count()).toBe(0);
} finally { await page.close(); }
});
test('commit detail shows verified signature row for signed commit', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commit/${hash}`);
const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
expect(await sigRow.isVisible()).toBe(true);
expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('commit detail shows no signature row for unsigned commit', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commit/${hash}`);
const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
expect(await sigRow.count()).toBe(0);
} finally { await page.close(); }
});
});
// ─── Repo sorting and pinning ─────────────────────────────────────────────────
describe('repo sorting and pinning', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create two repos with predictable names: sort-aaa (created first/older),
// sort-zzz (created second/newer). This lets us verify both name order and
// creation-time order independently.
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'sort-aaa');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/sort-aaa`);
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'sort-zzz');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/sort-zzz`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('sort dropdown is visible on repo list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
const options = await page.locator('.repo-sort-select option').allTextContents();
expect(options.some(t => t.includes('Newest'))).toBe(true);
expect(options.some(t => t.includes('Name'))).toBe(true);
} finally { await page.close(); }
});
test('newest option is selected by default', async () => {
// Use a fresh context to ensure no repo_sort cookie is set.
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page, 'admin', ADMIN_PASS);
await page.goto(BASE);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('created');
} finally { await ctx.close(); }
});
test('Go button is hidden with JS and works without JS', async () => {
const ctx = await browser.newContext({ javaScriptEnabled: false });
const page = await ctx.newPage();
try {
await login(page, 'admin', ADMIN_PASS);
await page.goto(BASE);
// Go button visible without JS
expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true);
// Select name sort and submit via Go button
await page.locator('.repo-sort-select').selectOption('name');
await page.locator('form[action="/sort"] button[type=submit]').click();
await page.waitForURL(BASE + '/');
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
} finally { await ctx.close(); }
});
test('selecting name sort sets cookie and persists on next visit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await Promise.all([
page.waitForURL(BASE + '/'),
page.locator('.repo-sort-select').selectOption('name'),
]);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
// Navigate away and back to confirm cookie persists
await page.goto(`${BASE}/my-repo`);
await page.goto(BASE);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
} finally {
// Reset cookie so subsequent tests start from the default sort.
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.close();
}
});
test('default sort shows newer repo before older repo', async () => {
const page = await adminCtx.newPage();
try {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa'));
} finally { await page.close(); }
});
test('name sort shows repos in alphabetical order', async () => {
const page = await adminCtx.newPage();
try {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]);
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
} finally {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.close();
}
});
test('pinning a repo shows pinned badge on list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/sort-aaa/settings`);
await page.check('[name=is_pinned]');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
expect(await card.locator('.badge-pinned').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('pinned repo appears before unpinned repos regardless of creation order', async () => {
const page = await adminCtx.newPage();
try {
// sort-aaa is pinned; default (newest) sort would normally show sort-zzz
// first since it's newer — but pinned repos float to the top.
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
} finally { await page.close(); }
});
test('unpinning a repo removes the pinned badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/sort-aaa/settings`);
await page.uncheck('[name=is_pinned]');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
expect(await card.locator('.badge-pinned').count()).toBe(0);
} finally { await page.close(); }
});
});
// ─── File editing ─────────────────────────────────────────────────────────────
describe('file editing', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a dedicated repo so edits don't interfere with other tests
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'edit-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/edit-repo`);
} finally { await page.close(); }
await seedRepo('edit-repo');
});
afterAll(async () => { await adminCtx.close(); });
test('Edit button appears on text file blob when viewing a branch as admin', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
const editBtn = page.locator('a[href*="/edit/main/index.js"]');
expect(await editBtn.isVisible()).toBe(true);
expect(await editBtn.textContent()).toBe('Edit');
} finally { await page.close(); }
});
test('Edit button does not appear when viewing a commit SHA', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`);
expect(await page.locator('a[href*="/edit/"]').count()).toBe(0);
} finally { await page.close(); }
});
test('Edit button does not appear for unauthenticated visitors', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0);
} finally {
await page.close();
await ctx.close();
}
});
test('edit page loads with file content pre-filled', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
const content = await page.locator('textarea[name=content]').inputValue();
expect(content).toContain('hello');
const msg = await page.locator('textarea[name=message]').inputValue();
expect(msg).toBe('Edited index.js');
} finally { await page.close(); }
});
test('edit page shows which branch will be committed to', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
expect(await page.content()).toContain('main');
} finally { await page.close(); }
});
test('edit page returns 404 for non-branch ref', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('submitting edit creates a new commit and redirects to blob view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
await page.fill('textarea[name=content]', 'console.log("edited");\n');
await page.fill('textarea[name=message]', 'Update index.js via web editor');
await page.locator('.form-actions button[type=submit]').click();
await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/);
// The commit detail view should show the commit message
expect(await page.content()).toContain('Update index.js via web editor');
} finally { await page.close(); }
});
test('edit commit has a gpgsig header (is signed)', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`;
const hash = (
await $`git -C ${repoDir} log --format=%H --grep="Update index.js via web editor" -1`.quiet()
).text().trim();
expect(hash).toBeTruthy();
const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
expect(obj).toContain('gpgsig');
});
test('edit commit shows verified badge in commit log', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/commits/main`);
const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' });
expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('GET edit page returns 404 for non-branch ref', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
});
// ─── Labels ───────────────────────────────────────────────────────────────────
describe('labels', () => {
let adminCtx: BrowserContext;
let issueUrl: string;
let patchUrl: string;
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add label-test.txt',
'',
'---',
'diff --git a/label-test.txt b/label-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/label-test.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a dedicated repo for label tests
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'label-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/label-repo`);
} finally { await page.close(); }
// Create an issue
const issuePage = await adminCtx.newPage();
try {
await issuePage.goto(`${BASE}/label-repo/issues/new`);
await issuePage.fill('[name=title]', 'Labelled issue');
await issuePage.click('form[action$="/issues"] button[type=submit]');
await issuePage.waitForURL(/\/label-repo\/issues\/\d+/);
issueUrl = issuePage.url();
} finally { await issuePage.close(); }
// Create a patch
writeTempFile('/tmp/label-test.patch', VALID_PATCH);
const patchPage = await adminCtx.newPage();
try {
await patchPage.goto(`${BASE}/label-repo/patches/new`);
await patchPage.fill('[name=title]', 'Labelled patch');
await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch');
await patchPage.click('form[action$="/patches"] button[type=submit]');
await patchPage.waitForURL(/\/label-repo\/patches\/\d+/);
patchUrl = patchPage.url();
} finally { await patchPage.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('create label in repo settings', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'bug');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#ff0000'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('create a second label', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'enhancement');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#00aa00'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
const badges = await page.locator('.label-settings-name').allTextContents();
expect(badges).toContain('bug');
expect(badges).toContain('enhancement');
} finally { await page.close(); }
});
test('duplicate label name is rejected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'bug');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#0000ff'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
expect(await page.locator('.form-error').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('non-admin cannot create labels', async () => {
const ctx = await browser.newContext();
try {
const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, {
form: { name: 'nope', color: '#123456' },
maxRedirects: 0,
});
// Unauthenticated → redirected to /login
expect(r.status()).toBe(302);
expect(r.headers()['location']).toContain('/login');
} finally { await ctx.close(); }
});
test('assign label to issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.selectOption('select[name=label_id]', { label: 'bug' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('label appears on issue list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' });
expect(await item.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('filter issues by label shows only matching issues', async () => {
// Create a second issue without the label
const createPage = await adminCtx.newPage();
try {
await createPage.goto(`${BASE}/label-repo/issues/new`);
await createPage.fill('[name=title]', 'Unlabelled issue');
await createPage.click('form[action$="/issues"] button[type=submit]');
await createPage.waitForURL(/\/label-repo\/issues\/\d+/);
} finally { await createPage.close(); }
// Open filter popup and apply label filter
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
// Get the label id from the checkbox
const checkbox = page.locator('.label-filter-item input[name=labels]').first();
const labelId = await checkbox.getAttribute('value');
expect(labelId).toBeTruthy();
// Navigate with the filter applied via URL
await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Labelled issue'))).toBe(true);
expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false);
} finally { await page.close(); }
});
test('filter popup is visible without JS', async () => {
// details/summary is a native HTML element — verify it renders
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
expect(await page.locator('details.label-filter').isVisible()).toBe(true);
expect(await page.locator('details.label-filter summary').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('remove label from issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
// Label badge should no longer appear in the labels row
const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(labelBadges).not.toContain('bug');
} finally { await page.close(); }
});
test('assign label to patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(patchUrl);
await page.selectOption('select[name=label_id]', { label: 'enhancement' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement');
} finally { await page.close(); }
});
test('label appears on patch list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/patches`);
const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' });
expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement');
} finally { await page.close(); }
});
test('filter patches by label', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/patches`);
// Find the checkbox for the 'enhancement' label specifically
const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' })
.locator('input[name=labels]');
const labelId = await checkbox.getAttribute('value');
expect(labelId).toBeTruthy();
await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Labelled patch'))).toBe(true);
} finally { await page.close(); }
});
test('remove label from patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(patchUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(labelBadges).not.toContain('enhancement');
} finally { await page.close(); }
});
test('delete label removes it from settings list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' });
await bugItem.locator('form[action$="/labels/delete"] button').click();
await page.waitForURL(/\/label-repo\/settings/);
const badges = await page.locator('.label-settings-name').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
});
test('deleted label no longer appears in filter popup', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
const filterLabels = await page.locator('.label-filter-item').allTextContents();
expect(filterLabels.every(t => !t.includes('bug'))).toBe(true);
} finally { await page.close(); }
});
});
// ─── User label management ────────────────────────────────────────────────────
describe('user label management', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
// URL of an issue owned by alice
let aliceIssueUrl: string;
// URL of an issue owned by admin
let adminIssueUrl: string;
// label IDs, fetched from the filter inputs
let bugLabelId: string;
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add ulm-test.txt',
'',
'---',
'diff --git a/ulm-test.txt b/ulm-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/ulm-test.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create dedicated repo
const repoPage = await adminCtx.newPage();
try {
await repoPage.goto(`${BASE}/new`);
await repoPage.fill('[name=name]', 'ulm-repo');
await repoPage.click('form[action="/new"] button[type=submit]');
await repoPage.waitForURL(`${BASE}/ulm-repo`);
} finally { await repoPage.close(); }
// Create labels 'bug' and 'feature'
for (const name of ['bug', 'feature']) {
const p = await adminCtx.newPage();
try {
await p.goto(`${BASE}/ulm-repo/settings`);
await p.fill('input[name=name]', name);
await p.click('form[action$="/settings/labels"] button[type=submit]');
await p.waitForURL(/\/ulm-repo\/settings/);
} finally { await p.close(); }
}
// Create an issue owned by admin
const adminIssuePage = await adminCtx.newPage();
try {
await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
await adminIssuePage.fill('[name=title]', "Admin's issue");
await adminIssuePage.click('form[action$="/issues"] button[type=submit]');
await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
adminIssueUrl = adminIssuePage.url();
} finally { await adminIssuePage.close(); }
// Create an issue owned by alice
const aliceIssuePage = await aliceCtx.newPage();
try {
await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
await aliceIssuePage.fill('[name=title]', "Alice's issue");
await aliceIssuePage.click('form[action$="/issues"] button[type=submit]');
await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
aliceIssueUrl = aliceIssuePage.url();
} finally { await aliceIssuePage.close(); }
// Grab the bug label id from the filter popup
const filterPage = await adminCtx.newPage();
try {
await filterPage.goto(`${BASE}/ulm-repo/issues`);
const checkbox = filterPage.locator('.label-filter-item')
.filter({ hasText: 'bug' })
.locator('input[name=labels]');
bugLabelId = (await checkbox.getAttribute('value')) ?? '';
} finally { await filterPage.close(); }
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
// ── Settings ──
test('allow_user_labels checkbox is present in repo settings', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1);
} finally { await page.close(); }
});
test('allow_user_labels is off by default', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
} finally { await page.close(); }
});
// ── Label checkboxes hidden when setting is off ──
test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
test('label checkboxes shown to admin on new issue form regardless of setting', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Enable the setting ──
test('admin can enable allow_user_labels', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
await page.check('input[name=allow_user_labels]');
await page.click('form[action$="/settings"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/settings/);
// Verify it persisted
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true);
} finally { await page.close(); }
});
// ── Label checkboxes visible when setting is on ──
test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents();
expect(labels).toContain('bug');
expect(labels).toContain('feature');
} finally { await page.close(); }
});
test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Creating with labels selected ──
test('non-admin can create issue with label selected', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
await page.fill('[name=title]', 'Issue with label');
// Check the 'bug' label checkbox
await page.locator('.label-checkbox-item').filter({ hasText: 'bug' })
.locator('input[type=checkbox]').check();
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/issues\/\d+/);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).toContain('bug');
} finally { await page.close(); }
});
test('non-admin can create patch with label selected', async () => {
writeTempFile('/tmp/ulm-test.patch', VALID_PATCH);
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
await page.fill('[name=title]', 'Patch with label');
await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch');
await page.locator('.label-checkbox-item').filter({ hasText: 'feature' })
.locator('input[type=checkbox]').check();
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/patches\/\d+/);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).toContain('feature');
} finally { await page.close(); }
});
test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => {
// Temporarily disable the setting, post with label_ids, re-enable
await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
form: { description: '', default_branch: 'main' }, // no allow_user_labels
maxRedirects: 0,
}).catch(() => {});
const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, {
form: { title: 'Issue sneaking labels', label_ids: bugLabelId },
maxRedirects: 0,
}).catch(() => null);
// Should redirect to the new issue
const location = resp?.headers()['location'] ?? '';
const issueNum = location.split('/issues/')[1];
if (issueNum) {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
}
// Re-enable for subsequent tests
await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
form: { description: '', default_branch: 'main', allow_user_labels: '1' },
maxRedirects: 0,
}).catch(() => {});
});
// ── Add/remove labels on existing items ──
test('non-admin can add label to their own issue', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const page = await aliceCtx.newPage();
try {
await page.goto(aliceIssueUrl);
await page.selectOption('select[name=label_id]', { label: 'bug' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('non-admin can remove label from their own issue', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const page = await aliceCtx.newPage();
try {
await page.goto(aliceIssueUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
const badges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
});
test('non-admin cannot add label to another user\'s issue', async () => {
const issueNum = adminIssueUrl.split('/issues/')[1];
const resp = await aliceCtx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(403);
});
test('unauthenticated user gets 401 adding a label', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const ctx = await browser.newContext();
try {
const resp = await ctx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(401);
} finally { await ctx.close(); }
});
// ── Disable setting and verify enforcement ──
test('admin can disable allow_user_labels', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
await page.uncheck('input[name=allow_user_labels]');
await page.click('form[action$="/settings"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/settings/);
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
} finally { await page.close(); }
});
test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const resp = await aliceCtx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(403);
});
test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
});
Mtests/helpers.ts
@@ -1,12 +1,21 @@
import { $ } from 'bun';
import { createServer } from 'net';
import { rmSync, mkdirSync, writeFileSync } from 'fs';
import { spawnSync as nodeSpawnSync } from 'child_process';
import path from 'path';
import type { Page } from 'playwright';
import config from '../src/config.ts';
import { createApp } from '../src/app.ts';
import { resetDb } from '../src/db/index.ts';
export const DATA_DIR = './data-test';
export const ADMIN_PASS = 'testpass123';
// Override config for tests — this runs at import time, before any app code
// reads these values, so no preload script is needed.
config.DATA_DIR = path.resolve(DATA_DIR);
config.RATE_LIMIT_DISABLED = true;
config.SSH_DISABLED = true;
/** Bind to port 0 and return the OS-assigned free port number. */
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -23,46 +32,30 @@ function getFreePort(): Promise<number> {
export let PORT = 0;
export let BASE = '';
/** Server handle — an Elysia app instance running in-process. */
export type ServerHandle = Awaited<ReturnType<typeof createApp>>;
export async function setupTestEnv() {
PORT = await getFreePort();
BASE = `http://localhost:${PORT}`;
rmSync(DATA_DIR, { recursive: true, force: true });
mkdirSync(`${DATA_DIR}/repos`, { recursive: true });
const init = Bun.spawn(['bun', 'run', 'src/db/init.ts'], {
nodeSpawnSync('bun', ['run', 'src/db/init.ts'], {
env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS },
stdout: 'pipe',
stderr: 'pipe',
stdio: 'ignore',
});
await init.exited;
// Reopen DB connection to the freshly-created database file.
resetDb();
}
export function spawnServer() {
return Bun.spawn(['bun', 'run', 'src/index.tsx'], {
env: {
...process.env,
PORT: String(PORT),
DATA_DIR,
RATE_LIMIT_DISABLED: 'true',
SSH_DISABLED: 'true',
},
stdout: 'ignore',
stderr: 'ignore',
});
export async function spawnServer(): Promise<ServerHandle> {
return createApp(PORT);
}
export async function waitForServer(maxMs = 10_000) {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
try {
await fetch(BASE);
return;
} catch {
await Bun.sleep(150);
}
}
throw new Error('Server did not start in time');
export async function killServer(app: ServerHandle): Promise<void> {
await app.stop(true);
}
export async function login(
@@ -82,21 +75,29 @@ export async function logout(page: Page) {
await page.waitForURL(BASE + '/');
}
const git = (args: string[], cwd?: string) =>
nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: 'ignore' });
export function gitOutput(args: string[], cwd?: string): string {
const r = nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: ['ignore', 'pipe', 'ignore'] });
return r.stdout?.toString().trim() ?? '';
}
/** Push an initial commit into a bare repo that already exists on disk. */
export async function seedRepo(name: string) {
export function seedRepo(name: string) {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
const tmp = `/tmp/hf-seed-${Date.now()}`;
try {
await $`git clone ${repoPath} ${tmp}`.quiet();
await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
await $`git -C ${tmp} config user.name "Test"`.quiet();
git(['clone', repoPath, tmp]);
git(['config', 'user.email', 'test@test.com'], tmp);
git(['config', 'user.name', 'Test'], tmp);
writeFileSync(`${tmp}/README.md`, `# ${name}\n`);
writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`);
await $`git -C ${tmp} add -A`.quiet();
await $`git -C ${tmp} commit -m "Initial commit"`.quiet();
await $`git -C ${tmp} push origin HEAD:main`.quiet();
git(['add', '-A'], tmp);
git(['commit', '-m', 'Initial commit'], tmp);
git(['push', 'origin', 'HEAD:main'], tmp);
} finally {
await $`rm -rf ${tmp}`.quiet().nothrow();
rmSync(tmp, { recursive: true, force: true });
}
}
@@ -105,42 +106,45 @@ export function writeTempFile(path: string, content: string) {
}
/** Commit a subdirectory with the given files into an existing repo on main. */
export async function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) {
export function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
const tmp = `/tmp/hf-subdir-${Date.now()}`;
try {
await $`git clone ${repoDir} ${tmp}`.quiet();
await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
await $`git -C ${tmp} config user.name "Test"`.quiet();
git(['clone', repoDir, tmp]);
git(['config', 'user.email', 'test@test.com'], tmp);
git(['config', 'user.name', 'Test'], tmp);
mkdirSync(path.join(tmp, dirPath), { recursive: true });
for (const [fileName, content] of Object.entries(files)) {
writeFileSync(path.join(tmp, dirPath, fileName), content);
}
await $`git -C ${tmp} add -A`.quiet();
await $`git -C ${tmp} commit -m ${'Add ' + dirPath}`.quiet();
await $`git -C ${tmp} push origin HEAD:main`.quiet();
git(['add', '-A'], tmp);
git(['commit', '-m', `Add ${dirPath}`], tmp);
git(['push', 'origin', 'HEAD:main'], tmp);
} finally {
await $`rm -rf ${tmp}`.quiet().nothrow();
rmSync(tmp, { recursive: true, force: true });
}
}
/** Return the HEAD commit hash of a repo. */
export async function getHeadCommit(repoName: string): Promise<string> {
export function getHeadCommit(repoName: string): string {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
return (await $`git -C ${repoPath} rev-parse HEAD`.quiet()).text().trim();
const result = nodeSpawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'],
});
return result.stdout?.toString().trim() ?? '';
}
/** Create a new branch in an existing repo (from current HEAD). */
export async function seedBranch(name: string, branchName: string) {
export function seedBranch(name: string, branchName: string) {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
const tmp = `/tmp/hf-branch-${Date.now()}`;
try {
await $`git clone ${repoPath} ${tmp}`.quiet();
await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
await $`git -C ${tmp} config user.name "Test"`.quiet();
await $`git -C ${tmp} checkout -b ${branchName}`.quiet();
await $`git -C ${tmp} push origin ${branchName}`.quiet();
git(['clone', repoPath, tmp]);
git(['config', 'user.email', 'test@test.com'], tmp);
git(['config', 'user.name', 'Test'], tmp);
git(['checkout', '-b', branchName], tmp);
git(['push', 'origin', branchName], tmp);
} finally {
await $`rm -rf ${tmp}`.quiet().nothrow();
rmSync(tmp, { recursive: true, force: true });
}
}