From 320e825bf817c3ae1d083123f3456891cd3ab65e Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sat, 15 Aug 2026 17:30:51 +0800 Subject: [PATCH] chore(quality): provider fixtures, formatter snapshots, platform contract tests, CI, CodeQL, dependabot --- .github/dependabot.yml | 10 +++ .github/workflows/ci.yml | 18 ++++ .github/workflows/codeql.yml | 26 ++++++ AGENTS.md | 9 ++ docs/contributing.md | 12 +++ docs/zh/contributing.md | 10 +++ .../formatter-snapshot.test.ts.snap | 84 +++++++++++++++++++ tests/fixtures/custom.ts | 9 ++ tests/fixtures/gitea.ts | 20 +++++ tests/fixtures/github.ts | 66 +++++++++++++++ tests/fixtures/index.ts | 3 + tests/formatter-snapshot.test.ts | 34 ++++++++ tests/platform-contract.test.ts | 44 ++++++++++ tests/provider-fixtures.test.ts | 55 ++++++++++++ wrangler.jsonc | 8 +- 15 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 tests/__snapshots__/formatter-snapshot.test.ts.snap create mode 100644 tests/fixtures/custom.ts create mode 100644 tests/fixtures/gitea.ts create mode 100644 tests/fixtures/github.ts create mode 100644 tests/fixtures/index.ts create mode 100644 tests/formatter-snapshot.test.ts create mode 100644 tests/platform-contract.test.ts create mode 100644 tests/provider-fixtures.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5a32bd8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0f8ebc4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun test + - run: bun run lint diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5a1cdbd --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,26 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 0 * * 0" + +permissions: + security-events: write + actions: read + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/AGENTS.md b/AGENTS.md index 010f21a..9e0d066 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,8 @@ server/ # Nitro server └── locales/ # en.ts, zh.ts translation dictionaries tests/ # bun test unit tests (webhook, formatter, discord, telegram, admin, groups, invites, audit, send-log, token-store, ...) +tests/fixtures/ # provider payload fixtures (github/gitea/custom) feeding provider + formatter tests +tests/__snapshots__/ # formatter snapshot golden files (toMatchSnapshot) ``` ## Responsibilities @@ -200,6 +202,13 @@ bun run lint # ESLint bun test # Unit tests (under tests/) ``` +Test suites beyond the per-module unit tests: provider fixtures (`tests/fixtures/` + +`tests/provider-fixtures.test.ts`), formatter snapshots (`tests/formatter-snapshot.test.ts` → +`tests/__snapshots__/`), and platform contract tests (`tests/platform-contract.test.ts`, which +assert the Discord/Telegram renderers clamp to their platform limits). CI +(`.github/workflows/ci.yml`) runs `bun install --frozen-lockfile` + `bun test` + `bun run lint`; +CodeQL (`codeql.yml`) and Dependabot (`dependabot.yml`) are configured under `.github/`. + ## Documentation Keep every functional change in sync with the docs. After implementing a feature, fix, diff --git a/docs/contributing.md b/docs/contributing.md index e5239f8..1e9ff4a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -75,8 +75,14 @@ server/ # Nitro server (H3 handlers in server/routes/) └── locales/ # en.ts, zh.ts translation dictionaries tests/ # Unit tests (bun test) +tests/fixtures/ # Provider payload fixtures (github/gitea/custom) +tests/__snapshots__/ # Formatter snapshot golden files ``` +Continuous integration (`.github/workflows/ci.yml`) runs `bun install --frozen-lockfile`, +`bun test`, and `bun run lint`. CodeQL (`codeql.yml`) and Dependabot (`dependabot.yml`) are +configured under `.github/`. + ## Scripts | Command | Description | @@ -112,6 +118,12 @@ bun test curl http://localhost:8787/health ``` +Beyond the per-module unit tests, the suite includes provider fixtures +(`tests/fixtures/` + `tests/provider-fixtures.test.ts`), formatter snapshots +(`tests/formatter-snapshot.test.ts`), and platform contract tests +(`tests/platform-contract.test.ts`) that assert the Discord/Telegram renderers clamp to their +platform limits. + ## Adding a New Event Formatter 1. Add the event type to `GITHUB_COLORS` in `server/lib/formatters/colors.ts` (if new color needed) diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md index 1700a84..ca80d3f 100644 --- a/docs/zh/contributing.md +++ b/docs/zh/contributing.md @@ -75,8 +75,13 @@ server/ # Nitro 服务器(H3 处理器位于 server/routes/ └── locales/ # en.ts、zh.ts 翻译字典 tests/ # 单元测试 (bun test) +tests/fixtures/ # 提供方负载固件 (github/gitea/custom) +tests/__snapshots__/ # 格式化器快照黄金文件 ``` +持续集成(`.github/workflows/ci.yml`)运行 `bun install --frozen-lockfile`、`bun test` 与 +`bun run lint`。CodeQL(`codeql.yml`)与 Dependabot(`dependabot.yml`)配置在 `.github/` 下。 + ## 脚本 | 命令 | 说明 | @@ -112,6 +117,11 @@ bun test curl http://localhost:8787/health ``` +除各模块单元测试外,测试套件还包含提供方固件(`tests/fixtures/` + +`tests/provider-fixtures.test.ts`)、格式化器快照(`tests/formatter-snapshot.test.ts`)以及 +平台契约测试(`tests/platform-contract.test.ts`,断言 Discord/Telegram 渲染器会截断到各自的 +平台限制)。 + ## 添加新事件格式化器 1. 将事件类型添加到 `server/lib/formatters/colors.ts` 中的 `GITHUB_COLORS`(如果需要新颜色) diff --git a/tests/__snapshots__/formatter-snapshot.test.ts.snap b/tests/__snapshots__/formatter-snapshot.test.ts.snap new file mode 100644 index 0000000..a312325 --- /dev/null +++ b/tests/__snapshots__/formatter-snapshot.test.ts.snap @@ -0,0 +1,84 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`formatter snapshots push 1`] = ` +{ + "author": { + "iconUrl": undefined, + "name": "octocat", + "url": "https://github.com/octocat", + }, + "color": 3056719, + "description": "[View comparison](https://github.com/acme/widget/compare/aaa...bbb)", + "fields": [ + { + "inline": false, + "name": "​", + "value": "[\`bbbbbbb\`](https://github.com/acme/widget/commit/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb) Fix login bug", + }, + ], + "footer": "acme/widget", + "timestamp": "TIMESTAMP", + "title": "acme/widget: Pushed 1 commit to [\`main\`](https://github.com/acme/widget/tree/main)", + "url": "https://github.com/acme/widget/compare/aaa...bbb", +} +`; + +exports[`formatter snapshots pull_request 1`] = ` +{ + "actions": [ + { + "id": "ghpr|merge|acme|widget|7", + "label": "Merge", + "style": "primary", + }, + { + "id": "ghpr|close|acme|widget|7", + "label": "Close", + "style": "danger", + }, + ], + "author": { + "iconUrl": undefined, + "name": "octocat", + "url": "https://github.com/octocat", + }, + "color": 2991182, + "description": +"🟢 **Opened** pull request + +Implements the new feature." +, + "fields": [ + { + "inline": true, + "name": "Branch", + "value": "[\`feature\`](https://github.com/acme/widget/tree/feature) → [\`main\`](https://github.com/acme/widget/tree/main)", + }, + ], + "footer": "acme/widget", + "timestamp": "TIMESTAMP", + "title": "acme/widget#7: Add feature", + "url": "https://github.com/acme/widget/pull/7", +} +`; + +exports[`formatter snapshots issues 1`] = ` +{ + "author": { + "iconUrl": undefined, + "name": "octocat", + "url": "https://github.com/octocat", + }, + "color": 2991182, + "description": +"🟢 **Opened** issue + +Login fails on iOS Safari." +, + "fields": undefined, + "footer": "acme/widget", + "timestamp": "TIMESTAMP", + "title": "acme/widget#12: Broken login on mobile", + "url": "https://github.com/acme/widget/issues/12", +} +`; diff --git a/tests/fixtures/custom.ts b/tests/fixtures/custom.ts new file mode 100644 index 0000000..896e474 --- /dev/null +++ b/tests/fixtures/custom.ts @@ -0,0 +1,9 @@ +export const customPayload: Record = { + deliveryId: "custom-123", + title: "Deploy finished", + repository: { + full_name: "acme/widget", + html_url: "https://github.com/acme/widget", + }, + message: "The latest build was deployed to staging.", +}; diff --git a/tests/fixtures/gitea.ts b/tests/fixtures/gitea.ts new file mode 100644 index 0000000..fc53cec --- /dev/null +++ b/tests/fixtures/gitea.ts @@ -0,0 +1,20 @@ +export const giteaPush: Record = { + ref: "refs/heads/main", + before: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + after: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + compare_url: "https://git.example.com/acme/widget/compare/aaa...bbb", + commits: [ + { + id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + message: "Fix login bug", + url: "https://git.example.com/acme/widget/commit/bbb", + timestamp: "2026-08-15T00:00:00Z", + author: { name: "Octocat", username: "octocat" }, + }, + ], + repository: { + full_name: "acme/widget", + html_url: "https://git.example.com/acme/widget", + }, + pusher: { login: "octocat" }, +}; diff --git a/tests/fixtures/github.ts b/tests/fixtures/github.ts new file mode 100644 index 0000000..8809d93 --- /dev/null +++ b/tests/fixtures/github.ts @@ -0,0 +1,66 @@ +export const githubPush: Record = { + ref: "refs/heads/main", + before: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + after: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + compare: "https://github.com/acme/widget/compare/aaa...bbb", + head_commit: { + id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + message: "Fix login bug", + timestamp: "2026-08-15T00:00:00Z", + url: "https://github.com/acme/widget/commit/bbb", + author: { name: "Octocat", username: "octocat" }, + }, + repository: { + full_name: "acme/widget", + html_url: "https://github.com/acme/widget", + }, + sender: { login: "octocat" }, + pusher: { name: "octocat" }, + commits: [ + { + id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + message: "Fix login bug", + url: "https://github.com/acme/widget/commit/bbb", + timestamp: "2026-08-15T00:00:00Z", + author: { name: "Octocat", username: "octocat" }, + }, + ], +}; + +export const githubPullRequest: Record = { + action: "opened", + number: 7, + pull_request: { + number: 7, + title: "Add feature", + html_url: "https://github.com/acme/widget/pull/7", + state: "open", + user: { login: "octocat" }, + body: "Implements the new feature.", + head: { ref: "feature" }, + base: { ref: "main" }, + merged: false, + }, + repository: { + full_name: "acme/widget", + html_url: "https://github.com/acme/widget", + }, + sender: { login: "octocat" }, +}; + +export const githubIssues: Record = { + action: "opened", + issue: { + number: 12, + title: "Broken login on mobile", + html_url: "https://github.com/acme/widget/issues/12", + state: "open", + user: { login: "octocat" }, + body: "Login fails on iOS Safari.", + }, + repository: { + full_name: "acme/widget", + html_url: "https://github.com/acme/widget", + }, + sender: { login: "octocat" }, +}; diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts new file mode 100644 index 0000000..cbca8c0 --- /dev/null +++ b/tests/fixtures/index.ts @@ -0,0 +1,3 @@ +export { githubPush, githubPullRequest, githubIssues } from "./github"; +export { giteaPush } from "./gitea"; +export { customPayload } from "./custom"; diff --git a/tests/formatter-snapshot.test.ts b/tests/formatter-snapshot.test.ts new file mode 100644 index 0000000..4c63bba --- /dev/null +++ b/tests/formatter-snapshot.test.ts @@ -0,0 +1,34 @@ +import { describe, test, expect } from "bun:test"; +import { formatEvent } from "../server/lib/formatters"; +import type { NeutralMessage, Route, WebhookEvent } from "../server/lib/types"; +import { githubPush, githubPullRequest, githubIssues } from "./fixtures"; + +const route: Route = { + id: "test", + name: "Test", + enabled: true, + filters: [], + targets: [{ channelId: "111" }], +}; + +function event(ev: string, payload: Record): WebhookEvent { + return { event: ev, payload }; +} + +function stable(msg: NeutralMessage): NeutralMessage { + return { ...msg, timestamp: msg.timestamp ? "TIMESTAMP" : msg.timestamp }; +} + +describe("formatter snapshots", () => { + test("push", () => { + expect(stable(formatEvent(route, event("push", githubPush)))).toMatchSnapshot(); + }); + + test("pull_request", () => { + expect(stable(formatEvent(route, event("pull_request", githubPullRequest)))).toMatchSnapshot(); + }); + + test("issues", () => { + expect(stable(formatEvent(route, event("issues", githubIssues)))).toMatchSnapshot(); + }); +}); diff --git a/tests/platform-contract.test.ts b/tests/platform-contract.test.ts new file mode 100644 index 0000000..f38f4e5 --- /dev/null +++ b/tests/platform-contract.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect } from "bun:test"; +import { renderNeutralMessage as renderDiscord } from "../server/lib/drivers/discord/render"; +import { renderNeutralMessage as renderTelegram } from "../server/lib/drivers/telegram/render"; +import { + MAX_TITLE, + MAX_DESCRIPTION, + MAX_FIELDS, + MAX_FIELD_VALUE, + MAX_FOOTER, +} from "../server/lib/formatters/helpers"; +import type { NeutralMessage } from "../server/lib/types"; + +const big = "x".repeat(10000); + +function oversized(): NeutralMessage { + return { + title: `acme/widget#1: ${big}`, + url: "https://github.com/acme/widget", + description: big, + fields: Array.from({ length: 30 }, (_, i) => ({ name: `field-${i}`, value: big })), + footer: big, + }; +} + +describe("platform contract", () => { + test("discord render clamps all limits", () => { + const embed = renderDiscord(oversized()).embeds?.[0]; + expect(embed).toBeDefined(); + expect(embed!.title!.length).toBeLessThanOrEqual(MAX_TITLE); + expect(embed!.description!.length).toBeLessThanOrEqual(MAX_DESCRIPTION); + expect(embed!.fields!.length).toBeLessThanOrEqual(MAX_FIELDS); + for (const f of embed!.fields!) { + expect(f.value.length).toBeLessThanOrEqual(MAX_FIELD_VALUE); + } + expect(embed!.footer).toBeDefined(); + expect(embed!.footer!.text.length).toBeLessThanOrEqual(MAX_FOOTER); + }); + + test("telegram render stays within the 4096-char cap", () => { + const html = renderTelegram(oversized()); + expect(html.length).toBeGreaterThan(4000); + expect(html.length).toBeLessThan(4200); + }); +}); diff --git a/tests/provider-fixtures.test.ts b/tests/provider-fixtures.test.ts new file mode 100644 index 0000000..3ead40d --- /dev/null +++ b/tests/provider-fixtures.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from "bun:test"; +import { formatEvent } from "../server/lib/formatters"; +import { detectProvider } from "../server/lib/providers"; +import type { Route, WebhookEvent } from "../server/lib/types"; +import { githubPush, githubPullRequest, githubIssues, giteaPush, customPayload } from "./fixtures"; + +const route: Route = { + id: "test", + name: "Test", + enabled: true, + filters: [], + targets: [{ channelId: "111" }], +}; + +function event(ev: string, payload: Record): WebhookEvent { + return { event: ev, payload }; +} + +describe("provider fixtures", () => { + const githubFixtures = [ + { event: "push", payload: githubPush }, + { event: "pull_request", payload: githubPullRequest }, + { event: "issues", payload: githubIssues }, + ]; + + for (const { event: ev, payload } of githubFixtures) { + test(`formats github ${ev} with a title`, () => { + const msg = formatEvent(route, event(ev, payload)); + expect(msg.title.length).toBeGreaterThan(0); + }); + + test(`github parse detects ${ev}`, () => { + const headers = { "x-github-event": ev }; + const parsed = detectProvider(headers)?.parse(JSON.stringify(payload), headers); + expect(parsed?.event).toBe(ev); + }); + } + + test("formats gitea push with a title", () => { + const msg = formatEvent(route, event("push", giteaPush)); + expect(msg.title.length).toBeGreaterThan(0); + }); + + test("gitea parse detects push", () => { + const headers = { "x-gitea-event": "push" }; + const parsed = detectProvider(headers)?.parse(JSON.stringify(giteaPush), headers); + expect(parsed?.event).toBe("push"); + }); + + test("custom parse detects custom event", () => { + const headers = { "x-webhooker-signature": "sha256=abc" }; + const parsed = detectProvider(headers)?.parse(JSON.stringify(customPayload), headers); + expect(parsed?.event).toBe("custom"); + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 7b5b8f3..7abe6cd 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,7 +2,9 @@ "name": "webhooker", "main": ".output/server/index.mjs", "compatibility_date": "2026-08-04", - "compatibility_flags": ["nodejs_compat"], + "compatibility_flags": [ + "nodejs_compat" + ], "build": { "command": "bun run build" }, @@ -32,8 +34,8 @@ "queues": { "producers": [ { + "queue": "webhooker-delivery", "binding": "QUEUE", - "queue": "webhooker-delivery" } ], "consumers": [ @@ -52,4 +54,4 @@ } ] }, -} +} \ No newline at end of file