From 6a10b3866ae7e5cf4fbb7e7d6bd1e06892367736 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 21:54:51 +0200 Subject: [PATCH 01/12] Formalise development workflow with enforced quality gates - CI (GitHub Actions): lint, full test suite, gitleaks secret scan on every PR and push to main - check.sh pre-commit gate: lint + gitleaks staged scan + tests in the test container (fails closed if gitleaks is missing) - bun test now enforces per-file coverage thresholds (95% lines, 90% functions); added tests for probeDuration, LogMessage debounce/edit failures, and handler error branches to bring every file above them - fix race in handlers.test.ts: clearPending was not awaited in beforeEach, so its unlink calls could steal queued mock implementations from the test body - restore the e2e gate in prod.sh - new lean CLAUDE.md documenting the workflow Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++++++ .simple-git-hooks.json | 2 +- CLAUDE.md | 35 ++++++++++++++++++++++++++++++ bunfig.toml | 3 +++ check.sh | 21 ++++++++++++++++++ prod.sh | 3 +-- test/download-video.test.ts | 34 +++++++++++++++++++++++++++++ test/handlers.test.ts | 43 +++++++++++++++++++++++++++++++++++-- test/log-message.test.ts | 25 +++++++++++++++++++++ 9 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md create mode 100755 check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d27587 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +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 + - run: bun install --frozen-lockfile + - run: bunx oxlint --deny-warnings + # download-video.ts writes its info cache under /storage at import time + - run: sudo mkdir -p -m 777 /storage + - run: bun test + env: + BOT_TOKEN: dummy + OWNER_ID: '0' + + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.simple-git-hooks.json b/.simple-git-hooks.json index bf80003..2e5b9d0 100644 --- a/.simple-git-hooks.json +++ b/.simple-git-hooks.json @@ -1,3 +1,3 @@ { - "pre-commit": "bun run lint" + "pre-commit": "./check.sh" } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ded4014 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# mp4ify-bot + +Telegram bot (@mp4ify_bot): send it a video link, it downloads with yt-dlp and +replies with the mp4. Bun + Telegraf, deployed via Docker Compose alongside a +local telegram-bot-api server (raises the upload limit to 2GB). + +## Commands + +- Test: `UID=$(id -u) GID=$(id -g) docker compose run --rm --no-deps test bun test` + (host runs fail: /storage is root-owned; tests must run in the test container) +- Full quality gate (lint + secret scan + tests): `./check.sh` — same thing the + pre-commit hook runs +- Deploy: `./prod.sh` (e2e-gated). Owner's call only — never deploy unprompted. +- Dev bot with hot reload: `./dev.sh` + +## Workflow + +- Features go on a branch with a PR; CI (lint, tests, coverage thresholds, + gitleaks) must be green to merge. Trivial fixes (typos, config) may go + straight to main. +- Every behavior change ships with tests covering it. Coverage thresholds live + in bunfig.toml — if they block you, write better tests, don't lower them. +- Run /code-review on the PR before handing it to the owner. +- A Stop hook blocks ending a session with uncommitted changes. + +## Gotchas + +- X/Twitter is intentionally unsupported for moral reasons. Do not add support. +- yt-dlp self-updates at startup and daily (world-writable /opt/yt-dlp in the + containers), so the version baked into the image is only a starting point. +- Verify Telegram API behavior empirically: capture real payloads, keep + MockBotApi in parity (test/simulate-bot-api.ts). The official docs are + incomplete or wrong in places. +- yt-dlp needs --no-check-certificates in cloud sandboxes; the code adds it + automatically when CLAUDE_CODE_REMOTE=true. diff --git a/bunfig.toml b/bunfig.toml index e5c8d19..ffe1fa7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,3 +2,6 @@ # This has to load first so that the mock of node-fetch applies to Telegraf: preload = ["./test/simulate-bot-api"] coverage = true +# Quality gate: bun test fails if any file's coverage drops below these. +# If they block you, write better tests - don't lower them. +coverageThreshold = { lines = 0.95, functions = 0.9 } diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..d6f8734 --- /dev/null +++ b/check.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Quality gate: lint, secret scan, full test suite (with coverage thresholds). +# Runs as the pre-commit hook; can also be run manually at any time. +set -e + +bun run lint + +if command -v gitleaks >/dev/null 2>&1; then + gitleaks git --pre-commit --staged --no-banner --redact +else + echo "ERROR: gitleaks not installed - refusing to commit unscanned changes." >&2 + echo "Install: https://github.com/gitleaks/gitleaks/releases (single static binary)" >&2 + exit 1 +fi + +if command -v docker >/dev/null 2>&1; then + UID=$(id -u) GID=$(id -g) docker compose run --rm --no-deps test bun test +else + # No docker (e.g. cloud sandbox): run directly, needs a writable /storage + bun test +fi diff --git a/prod.sh b/prod.sh index 15eab77..fe12b1b 100755 --- a/prod.sh +++ b/prod.sh @@ -1,3 +1,2 @@ #!/bin/sh -#./e2e.sh && UID=$(id -u) GID=$(id -g) docker compose up prod bot-api --build -d -UID=$(id -u) GID=$(id -g) docker compose up prod bot-api --build -d +./e2e.sh && UID=$(id -u) GID=$(id -g) docker compose up prod bot-api --build -d diff --git a/test/download-video.test.ts b/test/download-video.test.ts index 4e5d685..bc8ba74 100644 --- a/test/download-video.test.ts +++ b/test/download-video.test.ts @@ -15,6 +15,7 @@ import * as fsPromises from 'node:fs/promises'; import { downloadVideo, getInfo, + probeDuration, sendInfo, sendVideo, updateYtdlp, @@ -146,6 +147,39 @@ describe('updateYtdlp', () => { }); }); +describe('probeDuration', () => { + it('returns the rounded duration from ffprobe', async () => { + mockSpawn.mockImplementationOnce(mockSpawnImpl('12.7\n')); + expect(await probeDuration('file.mp4')).toBe(13); + expect(mockSpawn.mock.calls[0]![0]).toEqual([ + 'ffprobe', + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'csv=p=0', + 'file.mp4', + ]); + }); + + it('returns undefined and logs when ffprobe fails', async () => { + const consoleError = spyOn(console, 'error').mockImplementation(mock()); + mockSpawn.mockImplementationOnce( + mockSpawnImpl('', 'No such file', { exitCode: 1 }), + ); + expect(await probeDuration('missing.mp4')).toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('ffprobe failed for missing.mp4'), + ); + }); + + it('returns undefined for unparseable output', async () => { + mockSpawn.mockImplementationOnce(mockSpawnImpl('N/A\n')); + expect(await probeDuration('weird.mp4')).toBeUndefined(); + }); +}); + describe('getInfo', () => { beforeEach(() => getInfo.cache.clear()); diff --git a/test/handlers.test.ts b/test/handlers.test.ts index 53f30df..709a11d 100644 --- a/test/handlers.test.ts +++ b/test/handlers.test.ts @@ -25,9 +25,11 @@ import { spyMock, } from './test-utils.ts'; -beforeEach(() => { +beforeEach(async () => { jest.clearAllMocks(); - pendingDownloads.clearPending(); + // Must await: a fire-and-forget cleanup races with the test body and can + // consume queued mockImplementationOnce's on the shared unlink spy. + await pendingDownloads.clearPending(); }); afterAll(() => mock.restore()); spyMock(console, 'debug'); // suppress debug logs @@ -319,6 +321,26 @@ describe('confirmation for long videos (>20 min)', () => { expect(mockDownloadVideo).not.toHaveBeenCalled(); }); + it('answers silently for malformed callback data', async () => { + const cbCtx = createMockCallbackCtx('garbage', 123); + await callbackQueryHandler(cbCtx as any); + expect(cbCtx.answerCbQuery).toHaveBeenCalledWith(''); + expect(mockDownloadVideo).not.toHaveBeenCalled(); + }); + + it('survives answerCbQuery failures', async () => { + const mockError = spyOn(console, 'error').mockImplementation(() => {}); + const cbCtx = createMockCallbackCtx('garbage', 123); + (cbCtx.answerCbQuery as any).mockImplementationOnce(() => + Promise.reject(new Error('query is too old')), + ); + await callbackQueryHandler(cbCtx as any); + expect(mockError).toHaveBeenCalledWith( + 'answerCbQuery failed:', + expect.any(Error), + ); + }); + it('responds with unavailable for unknown callback data', async () => { const cbCtx = createMockCallbackCtx('dl:nonexistent', 123); await callbackQueryHandler(cbCtx as any); @@ -511,6 +533,23 @@ describe('post-download duration check', () => { expect(mockSendVideo).toHaveBeenCalled(); }); + it('logs unexpected cleanup failures on cancel', async () => { + const { cancelData } = await triggerPostDownloadConfirmation(); + const mockError = spyOn(console, 'error').mockImplementation(() => {}); + mockUnlink.mockImplementationOnce(() => + Promise.reject(Object.assign(new Error('busy'), { code: 'EBUSY' })), + ); + + const cbCtx = createMockCallbackCtx(cancelData, 123); + await callbackQueryHandler(cbCtx as any); + + expect(cbCtx.answerCbQuery).toHaveBeenCalledWith('Cancelled.'); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('Failed to clean up'), + expect.any(Error), + ); + }); + it('deletes file and does not upload on cancel', async () => { const { cancelData } = await triggerPostDownloadConfirmation(); jest.clearAllMocks(); diff --git a/test/log-message.test.ts b/test/log-message.test.ts index e02e7a2..81cac5f 100644 --- a/test/log-message.test.ts +++ b/test/log-message.test.ts @@ -47,6 +47,31 @@ describe.each([false, true])('LogMessage, edit: %p', (isEdit) => { await log.flush(); expect(ctx.reply).not.toHaveBeenCalled(); }); + + it('flushes automatically after the debounce delay', async () => { + const ctx = createMockMessageCtx(isEdit); + new LogMessage(ctx, 'debounced'); + expect(ctx.reply).not.toHaveBeenCalled(); + await Bun.sleep(200); // DEBOUNCE_MS is 150 + expect(ctx.reply).toHaveBeenCalledWith('debounced', expect.anything()); + }); + + it('does not retry failed edits with the same content', async () => { + const ctx = createMockMessageCtx(isEdit); + const mockError = spyMock(console, 'error'); + mockError.mockClear(); // spy persists across the describe.each variants + const log = new LogMessage(ctx, 'foo'); + await log.flush(); + (ctx.telegram.editMessageText as any).mockRejectedValueOnce( + new Error('message is not modified'), + ); + log.append('bar'); + await log.flush(); + expect(mockError).toHaveBeenCalledTimes(1); + // Re-flushing the same content must not attempt another edit + await log.flush(); + expect(ctx.telegram.editMessageText).toHaveBeenCalledTimes(1); + }); }); describe('NoLog', () => { From ea2a425b5f8e89d0484fb57c59dece1f969af875 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 21:58:53 +0200 Subject: [PATCH 02/12] Replace gitleaks CI job with GitHub push protection Scanning in CI is too late - the secret is already pushed. Server-side push protection rejects the push itself; the pre-commit gitleaks scan catches it even earlier, before it enters history at all. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d27587..d68c125 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,13 +19,3 @@ jobs: env: BOT_TOKEN: dummy OWNER_ID: '0' - - gitleaks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From eec58f5fa63d38ada2d37bff9910dfd6c2314af1 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 22:02:33 +0200 Subject: [PATCH 03/12] Enforce green-to-merge and deploy-needs-approval mechanically - PreToolUse hook denies 'gh pr merge' when the PR's checks aren't green - permission ask rule makes ./prod.sh always require interactive approval - trim CLAUDE.md to conventions hooks can't enforce Co-Authored-By: Claude Fable 5 --- .claude/hooks/check-pr-green.sh | 10 ++++++++++ .claude/settings.json | 15 +++++++++++++++ CLAUDE.md | 12 +++++++----- 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100755 .claude/hooks/check-pr-green.sh diff --git a/.claude/hooks/check-pr-green.sh b/.claude/hooks/check-pr-green.sh new file mode 100755 index 0000000..fddd5c9 --- /dev/null +++ b/.claude/hooks/check-pr-green.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# PreToolUse gate: only allow `gh pr merge` when the PR's checks are green. +# Stdin: hook JSON with .tool_input.command containing the gh invocation. +cmd=$(jq -r '.tool_input.command // empty') +# First PR number or URL in the command; empty means "PR for current branch" +target=$(printf '%s' "$cmd" | tr ' ' '\n' | grep -m1 -E '^[0-9]+$|^https://' || true) +if out=$(gh pr checks $target 2>&1); then + exit 0 +fi +jq -n --arg out "$out" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: ("PR checks are not green; fix CI before merging.\n" + $out)}}' diff --git a/.claude/settings.json b/.claude/settings.json index 41e4436..133a63f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,20 @@ { + "permissions": { + "ask": ["Bash(./prod.sh*)"] + }, "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(gh pr merge*)", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-pr-green.sh" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/CLAUDE.md b/CLAUDE.md index ded4014..9a07834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,13 +15,15 @@ local telegram-bot-api server (raises the upload limit to 2GB). ## Workflow -- Features go on a branch with a PR; CI (lint, tests, coverage thresholds, - gitleaks) must be green to merge. Trivial fixes (typos, config) may go +Most quality gates are enforced mechanically (pre-commit: lint + gitleaks + +tests + coverage; hooks: commit-before-stop, green-CI-before-merge; GitHub +push protection). The conventions hooks can't enforce: + +- Features go on a branch with a PR; trivial fixes (typos, config) may go straight to main. -- Every behavior change ships with tests covering it. Coverage thresholds live - in bunfig.toml — if they block you, write better tests, don't lower them. +- Every behavior change ships with tests covering it. If the coverage + thresholds in bunfig.toml block you, write better tests — don't lower them. - Run /code-review on the PR before handing it to the owner. -- A Stop hook blocks ending a session with uncommitted changes. ## Gotchas From a3f00bb46f0e1ccc0d2c1ae0474e34cf1ba1e75c Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 22:14:11 +0200 Subject: [PATCH 04/12] Merge gate: resolve branch-name targets, quote the arg Found by /code-review: 'gh pr merge ' previously fell through to checking the current branch's PR instead of the named one. Co-Authored-By: Claude Fable 5 --- .claude/hooks/check-pr-green.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/check-pr-green.sh b/.claude/hooks/check-pr-green.sh index fddd5c9..9593432 100755 --- a/.claude/hooks/check-pr-green.sh +++ b/.claude/hooks/check-pr-green.sh @@ -2,9 +2,10 @@ # PreToolUse gate: only allow `gh pr merge` when the PR's checks are green. # Stdin: hook JSON with .tool_input.command containing the gh invocation. cmd=$(jq -r '.tool_input.command // empty') -# First PR number or URL in the command; empty means "PR for current branch" -target=$(printf '%s' "$cmd" | tr ' ' '\n' | grep -m1 -E '^[0-9]+$|^https://' || true) -if out=$(gh pr checks $target 2>&1); then +# Merge target = first non-flag arg after "merge": a PR number, URL, or +# branch name (gh pr checks accepts all three). Empty = PR for current branch. +target=$(printf '%s' "$cmd" | sed -n 's/.*gh pr merge//p' | tr ' ' '\n' | grep -m1 -v -e '^-' -e '^$' || true) +if out=$(gh pr checks ${target:+"$target"} 2>&1); then exit 0 fi jq -n --arg out "$out" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: ("PR checks are not green; fix CI before merging.\n" + $out)}}' From 9dce83d18668702d279a53ccc70607b423091651 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 22:28:27 +0200 Subject: [PATCH 05/12] Run e2e tests pre-push and in CI; refresh stale snapshots e2e uses MockBotApi (faker token) with real yt-dlp, so it needs no secrets and can run anywhere. Partial runs can't meet whole-suite coverage thresholds, so e2e.sh uses bunfig.e2e.toml (no coverage). e2e.sh drops -it so it works without a TTY (git hooks). Also per owner feedback: /code-review findings get fixed directly, not posted as comments on our own PRs (CLAUDE.md updated). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 19 +++++ .simple-git-hooks.json | 3 +- CLAUDE.md | 11 ++- bunfig.e2e.toml | 4 ++ e2e.sh | 2 +- test/__snapshots__/e2e.test.ts.snap | 108 ++++++++++++++++++++++------ 6 files changed, 119 insertions(+), 28 deletions(-) create mode 100644 bunfig.e2e.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d68c125..bb4e90e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,22 @@ jobs: env: BOT_TOKEN: dummy OWNER_ID: '0' + + # Real yt-dlp downloads against a mocked Telegram API. May prove flaky from + # datacenter IPs (sites rate-limit them harder) - if so, demote or remove. + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: | + sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg + sudo curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp + sudo chmod a+rx /usr/local/bin/yt-dlp + - run: sudo mkdir -p -m 777 /storage + - run: bun --config=bunfig.e2e.toml test e2e + env: + TEST_E2E: 'true' + BOT_TOKEN: dummy + OWNER_ID: '0' diff --git a/.simple-git-hooks.json b/.simple-git-hooks.json index 2e5b9d0..6cf6b75 100644 --- a/.simple-git-hooks.json +++ b/.simple-git-hooks.json @@ -1,3 +1,4 @@ { - "pre-commit": "./check.sh" + "pre-commit": "./check.sh", + "pre-push": "./e2e.sh" } diff --git a/CLAUDE.md b/CLAUDE.md index 9a07834..d637d98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,14 +16,19 @@ local telegram-bot-api server (raises the upload limit to 2GB). ## Workflow Most quality gates are enforced mechanically (pre-commit: lint + gitleaks + -tests + coverage; hooks: commit-before-stop, green-CI-before-merge; GitHub -push protection). The conventions hooks can't enforce: +tests + coverage; pre-push: e2e; hooks: commit-before-stop, +green-CI-before-merge; GitHub push protection). The conventions hooks can't +enforce: - Features go on a branch with a PR; trivial fixes (typos, config) may go straight to main. - Every behavior change ships with tests covering it. If the coverage thresholds in bunfig.toml block you, write better tests — don't lower them. -- Run /code-review on the PR before handing it to the owner. +- Run /code-review before handing a PR to the owner, and FIX what it finds — + don't post review comments on your own PR. +- e2e snapshot mismatches where only yt-dlp format ids / filenames changed are + staleness, not bugs: refresh with + `docker compose run --rm -T test bash -c "TEST_E2E=true bun --config=bunfig.e2e.toml test e2e -u"` ## Gotchas diff --git a/bunfig.e2e.toml b/bunfig.e2e.toml new file mode 100644 index 0000000..4bdc63f --- /dev/null +++ b/bunfig.e2e.toml @@ -0,0 +1,4 @@ +# bunfig for e2e runs (./e2e.sh): no coverage thresholds, since a partial +# run can't meet whole-suite coverage. Keep preload in sync with bunfig.toml. +[test] +preload = ["./test/simulate-bot-api"] diff --git a/e2e.sh b/e2e.sh index e65ae1f..76a5088 100755 --- a/e2e.sh +++ b/e2e.sh @@ -1,2 +1,2 @@ #!/bin/sh -docker compose run --remove-orphans --rm -it test bash -c 'TEST_E2E=true bun test e2e' +docker compose run --remove-orphans --rm -T test bash -c "TEST_E2E=true bun --config=bunfig.e2e.toml test e2e" diff --git a/test/__snapshots__/e2e.test.ts.snap b/test/__snapshots__/e2e.test.ts.snap index 96b482d..a2de3f8 100644 --- a/test/__snapshots__/e2e.test.ts.snap +++ b/test/__snapshots__/e2e.test.ts.snap @@ -134,11 +134,34 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i "text": "🧐 Scraping https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6... -ERROR: [generic] Unable to download webpage: HTTP Error 403: Blocked (caused by <HTTPError 403: Blocked>) +🎬 Video info: + +URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +duration: 10 sec +size: 0.01 MB +resolution: 480x600 +video codec: avc1.4D401F @ 1023.275 kbps +audio codec: mp4a.40.2 @ 132.978 kbps + +⬇️ Downloading... -💥 Download failed: yt-dlp exited with code 1" +🚀 Uploading (1.20 MB)..." , }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 10, + "height": 600, + "reply_parameters": { + "message_id": 0, + }, + "reply_to_message_id": 0, + "supports_streaming": true, + "video": "file:///storage/Reddit/Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-%5Bauhgg4yoeo5f1%5D.hls-1023+dash-6.mp4", + "width": 480, + }, ] `; @@ -155,13 +178,31 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i "message_id": 1, }, "text": -"🧐 Scraping https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6... - -ERROR: [generic] Unable to download webpage: HTTP Error 403: Blocked (caused by <HTTPError 403: Blocked>) +" +🎬 Video info: -💥 Download failed: yt-dlp exited with code 1" +URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +duration: 10 sec +size: 0.01 MB +resolution: 480x600 +video codec: avc1.4D401F @ 1023.275 kbps +audio codec: mp4a.40.2 @ 132.978 kbps" , }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 10, + "height": 600, + "reply_parameters": { + "message_id": 1, + }, + "reply_to_message_id": 1, + "supports_streaming": true, + "video": "uufngxsbt7v5", + "width": 480, + }, ] `; @@ -178,13 +219,31 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i "message_id": 2, }, "text": -"🧐 Scraping https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6... - -ERROR: [generic] Unable to download webpage: HTTP Error 403: Blocked (caused by <HTTPError 403: Blocked>) +" +🎬 Video info: -💥 Download failed: yt-dlp exited with code 1" +URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +duration: 10 sec +size: 0.01 MB +resolution: 480x600 +video codec: avc1.4D401F @ 1023.275 kbps +audio codec: mp4a.40.2 @ 132.978 kbps" , }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 10, + "height": 600, + "reply_parameters": { + "message_id": 2, + }, + "reply_to_message_id": 2, + "supports_streaming": true, + "video": "uufngxsbt7v5", + "width": 480, + }, ] `; @@ -334,15 +393,16 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: downlo 🎬 Video info: URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].312+234-11.mp4 +filename: Zinger_Burgers-[0COu-qMC18Y].299+140.mp4 duration: 57 sec -size: 0.29 MB +size: 24.93 MB resolution: 1080x1920 -video codec: avc1.64002A @ 5327.966 kbps +video codec: avc1.64002a @ 3571.925 kbps +audio codec: mp4a.40.2 @ 129.585 kbps ⬇️ Downloading... -🚀 Uploading (24.97 MB)..." +🚀 Uploading (24.95 MB)..." , }, { @@ -355,7 +415,7 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: downlo }, "reply_to_message_id": 0, "supports_streaming": true, - "video": "file:///storage/youtube/Zinger_Burgers-%5B0COu-qMC18Y%5D.312+234-11.mp4", + "video": "file:///storage/youtube/Zinger_Burgers-%5B0COu-qMC18Y%5D.299+140.mp4", "width": 1080, }, ] @@ -378,11 +438,12 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: mem ca 🎬 Video info: URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].312+234-11.mp4 +filename: Zinger_Burgers-[0COu-qMC18Y].299+140.mp4 duration: 57 sec -size: 0.29 MB +size: 24.93 MB resolution: 1080x1920 -video codec: avc1.64002A @ 5327.966 kbps" +video codec: avc1.64002a @ 3571.925 kbps +audio codec: mp4a.40.2 @ 129.585 kbps" , }, { @@ -395,7 +456,7 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: mem ca }, "reply_to_message_id": 1, "supports_streaming": true, - "video": "2dne2ojllzgw8", + "video": "7jef1dex1i0d", "width": 1080, }, ] @@ -418,11 +479,12 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: disk c 🎬 Video info: URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].312+234-11.mp4 +filename: Zinger_Burgers-[0COu-qMC18Y].299+140.mp4 duration: 57 sec -size: 0.29 MB +size: 24.93 MB resolution: 1080x1920 -video codec: avc1.64002A @ 5327.966 kbps" +video codec: avc1.64002a @ 3571.925 kbps +audio codec: mp4a.40.2 @ 129.585 kbps" , }, { @@ -435,7 +497,7 @@ exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: disk c }, "reply_to_message_id": 2, "supports_streaming": true, - "video": "2dne2ojllzgw8", + "video": "7jef1dex1i0d", "width": 1080, }, ] From ad5be0a4e5a4fe934f4a7cb66cb2bc207afebc48 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 22:40:17 +0200 Subject: [PATCH 06/12] Drop e2e from CI: reddit/youtube hard-block datacenter IPs Empirically verified in run 27304285133 - not flakiness but auth walls. e2e remains as the pre-push hook and the prod.sh deploy gate. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 21 +++------------------ CLAUDE.md | 3 +++ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb4e90e..0b5c867 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,21 +20,6 @@ jobs: BOT_TOKEN: dummy OWNER_ID: '0' - # Real yt-dlp downloads against a mocked Telegram API. May prove flaky from - # datacenter IPs (sites rate-limit them harder) - if so, demote or remove. - e2e: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - run: bun install --frozen-lockfile - - run: | - sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg - sudo curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp - sudo chmod a+rx /usr/local/bin/yt-dlp - - run: sudo mkdir -p -m 777 /storage - - run: bun --config=bunfig.e2e.toml test e2e - env: - TEST_E2E: 'true' - BOT_TOKEN: dummy - OWNER_ID: '0' + # NOTE: no e2e job. Tried in 9dce83d: reddit/youtube hard-block GitHub's + # datacenter IPs (403 / auth walls), so e2e only works from residential + # IPs. It runs as the pre-push git hook instead (see .simple-git-hooks.json). diff --git a/CLAUDE.md b/CLAUDE.md index d637d98..266570b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,3 +40,6 @@ enforce: incomplete or wrong in places. - yt-dlp needs --no-check-certificates in cloud sandboxes; the code adds it automatically when CLAUDE_CODE_REMOTE=true. +- reddit/youtube hard-block datacenter IPs (403 / "account authentication + required"), so e2e tests only work from residential IPs — they run pre-push + and at deploy, deliberately NOT in CI or cloud sandboxes. From f40cfa63bc1520eaa630f83ac107d33bad7df0f8 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 22:42:27 +0200 Subject: [PATCH 07/12] Drop e2e from CI and drop youtube from e2e URLs CI: reddit/youtube hard-block GitHub datacenter IPs (verified in run 27304285133) - e2e stays as the pre-push hook and deploy gate. YouTube also rate-limits residential IPs when e2e runs more than a few times an hour, which a pre-push gate does; 571670e learned this once already. Instagram + both reddit URL forms still cover the pipeline. Co-Authored-By: Claude Fable 5 --- test/__snapshots__/e2e.test.ts.snap | 127 ---------------------------- test/e2e.test.ts | 5 +- 2 files changed, 3 insertions(+), 129 deletions(-) diff --git a/test/__snapshots__/e2e.test.ts.snap b/test/__snapshots__/e2e.test.ts.snap index a2de3f8..a34b347 100644 --- a/test/__snapshots__/e2e.test.ts.snap +++ b/test/__snapshots__/e2e.test.ts.snap @@ -375,130 +375,3 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com ] `; -exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: download 1`] = ` -[ - { - "chat_id": 1337, - "disable_notification": true, - "link_preview_options": { - "is_disabled": true, - }, - "parse_mode": "HTML", - "reply_parameters": { - "message_id": 0, - }, - "text": -"🧐 Scraping http://youtube.com/shorts/0COu-qMC18Y... - -🎬 Video info: - -URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].299+140.mp4 -duration: 57 sec -size: 24.93 MB -resolution: 1080x1920 -video codec: avc1.64002a @ 3571.925 kbps -audio codec: mp4a.40.2 @ 129.585 kbps - -⬇️ Downloading... - -🚀 Uploading (24.95 MB)..." -, - }, - { - "chat_id": 1337, - "disable_notification": true, - "duration": 57, - "height": 1920, - "reply_parameters": { - "message_id": 0, - }, - "reply_to_message_id": 0, - "supports_streaming": true, - "video": "file:///storage/youtube/Zinger_Burgers-%5B0COu-qMC18Y%5D.299+140.mp4", - "width": 1080, - }, -] -`; - -exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: mem cache 1`] = ` -[ - { - "chat_id": 1337, - "disable_notification": true, - "link_preview_options": { - "is_disabled": true, - }, - "parse_mode": "HTML", - "reply_parameters": { - "message_id": 1, - }, - "text": -" -🎬 Video info: - -URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].299+140.mp4 -duration: 57 sec -size: 24.93 MB -resolution: 1080x1920 -video codec: avc1.64002a @ 3571.925 kbps -audio codec: mp4a.40.2 @ 129.585 kbps" -, - }, - { - "chat_id": 1337, - "disable_notification": true, - "duration": 57, - "height": 1920, - "reply_parameters": { - "message_id": 1, - }, - "reply_to_message_id": 1, - "supports_streaming": true, - "video": "7jef1dex1i0d", - "width": 1080, - }, -] -`; - -exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: disk cache 1`] = ` -[ - { - "chat_id": 1337, - "disable_notification": true, - "link_preview_options": { - "is_disabled": true, - }, - "parse_mode": "HTML", - "reply_parameters": { - "message_id": 2, - }, - "text": -" -🎬 Video info: - -URL: https://www.youtube.com/watch?v=0COu-qMC18Y -filename: Zinger_Burgers-[0COu-qMC18Y].299+140.mp4 -duration: 57 sec -size: 24.93 MB -resolution: 1080x1920 -video codec: avc1.64002a @ 3571.925 kbps -audio codec: mp4a.40.2 @ 129.585 kbps" -, - }, - { - "chat_id": 1337, - "disable_notification": true, - "duration": 57, - "height": 1920, - "reply_parameters": { - "message_id": 2, - }, - "reply_to_message_id": 2, - "supports_streaming": true, - "video": "7jef1dex1i0d", - "width": 1080, - }, -] -`; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 6ab021c..66edc77 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -43,8 +43,9 @@ const testUrls = [ 'https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6', // canonical url for same video 'https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo', - // fails if we run the test too often - 'http://youtube.com/shorts/0COu-qMC18Y', + // NO youtube url: it rate-limits aggressively ("Sign in to confirm you're + // not a bot") when the suite runs more than a few times per hour, which a + // pre-push gate does. See 571670e for the first time we learned this. ]; const clearDiskCache = async () => $`rm -rf /storage/*`.catch(() => null); From 84b319fe339bf2c4b4ba47206f7723be0205ac98 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 23:02:39 +0200 Subject: [PATCH 08/12] Remove cloud-environment support (CLAUDE_CODE_REMOTE, docker fallback) Cloud IPs are blocked by the sites yt-dlp needs, so this project can't be developed from cloud sandboxes - drop the SSL-interception cert flags, the no-docker test fallback, and the CLAUDE.md mentions. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 7 +++---- check.sh | 7 +------ src/download-video.ts | 7 +------ test/download-video.test.ts | 3 --- 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 266570b..7bc7b16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,8 +38,7 @@ enforce: - Verify Telegram API behavior empirically: capture real payloads, keep MockBotApi in parity (test/simulate-bot-api.ts). The official docs are incomplete or wrong in places. -- yt-dlp needs --no-check-certificates in cloud sandboxes; the code adds it - automatically when CLAUDE_CODE_REMOTE=true. - reddit/youtube hard-block datacenter IPs (403 / "account authentication - required"), so e2e tests only work from residential IPs — they run pre-push - and at deploy, deliberately NOT in CI or cloud sandboxes. + required"), so anything involving real yt-dlp downloads only works from + residential IPs. This is why e2e runs pre-push and at deploy but NOT in CI, + and why this project can't be developed from cloud environments. diff --git a/check.sh b/check.sh index d6f8734..d0d0c8d 100755 --- a/check.sh +++ b/check.sh @@ -13,9 +13,4 @@ else exit 1 fi -if command -v docker >/dev/null 2>&1; then - UID=$(id -u) GID=$(id -g) docker compose run --rm --no-deps test bun test -else - # No docker (e.g. cloud sandbox): run directly, needs a writable /storage - bun test -fi +UID=$(id -u) GID=$(id -g) docker compose run --rm --no-deps test bun test diff --git a/src/download-video.ts b/src/download-video.ts index 2e47f0c..d171553 100644 --- a/src/download-video.ts +++ b/src/download-video.ts @@ -44,16 +44,12 @@ export type VideoInfo = { }[]; }; -// Cloud environments (e.g. Claude Code Web) often have SSL interception -const certFlags = () => - Bun.env.CLAUDE_CODE_REMOTE === 'true' ? ['--no-check-certificates'] : []; - // Self-update yt-dlp so extractors keep up with site changes (e.g. Reddit // requiring auth from older versions). Never throws: a failed update just // means we keep using the current version. export const updateYtdlp = async () => { try { - const proc = Bun.spawn(['yt-dlp', '--update', ...certFlags()], { + const proc = Bun.spawn(['yt-dlp', '--update'], { stdout: 'pipe', stderr: 'pipe', timeout: 120_000, @@ -85,7 +81,6 @@ const execYtdlp = async ( 'yt-dlp', url, verbose ? '--verbose' : '--no-warnings', - ...certFlags(), ...extraArgs, ]; console.debug(command.join(' ')); diff --git a/test/download-video.test.ts b/test/download-video.test.ts index bc8ba74..2fac72b 100644 --- a/test/download-video.test.ts +++ b/test/download-video.test.ts @@ -1,6 +1,3 @@ -// Ensure cloud-only flags don't affect test snapshots -delete Bun.env.CLAUDE_CODE_REMOTE; - import { afterAll, beforeEach, From 13292a8d5ca3e45121e8d5dd4cc9f40f60fff756 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 23:04:54 +0200 Subject: [PATCH 09/12] CLAUDE.md: stage review tooling across the dev workflow pr-review-toolkit agents during development (recall, triaged by the authoring Claude); /code-review --fix as the pre-handoff precision gate. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7bc7b16..983fffe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,13 @@ enforce: straight to main. - Every behavior change ships with tests covering it. If the coverage thresholds in bunfig.toml block you, write better tests — don't lower them. -- Run /code-review before handing a PR to the owner, and FIX what it finds — - don't post review comments on your own PR. +- While developing non-trivial changes, run the pr-review-toolkit agents that + match the work (silent-failure-hunter for error handling, pr-test-analyzer + for test quality, type-design-analyzer for new types, code-simplifier after + a big chunk). Fix the findings you agree with; dismiss bad ones with stated + reasoning. +- Before handing a PR to the owner, run /code-review --fix as the final + precision pass. Never post review comments on your own PR. - e2e snapshot mismatches where only yt-dlp format ids / filenames changed are staleness, not bugs: refresh with `docker compose run --rm -T test bash -c "TEST_E2E=true bun --config=bunfig.e2e.toml test e2e -u"` From 2706603287d5756f519e2e7f39de5bf225cc4a3e Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 23:10:36 +0200 Subject: [PATCH 10/12] Everything via PR: branch protection on main, drop merge-gate hook Ruleset 17521822 requires a PR + green 'test' check on main, no bypass (admins included), so the gh-pr-merge PreToolUse hook is redundant and removed. CLAUDE.md: no more trivial-changes-to-main path; every change gets review-pr during development and /code-review --fix at handoff. Co-Authored-By: Claude Fable 5 --- .claude/hooks/check-pr-green.sh | 11 ----------- .claude/settings.json | 12 ------------ CLAUDE.md | 21 ++++++++++----------- 3 files changed, 10 insertions(+), 34 deletions(-) delete mode 100755 .claude/hooks/check-pr-green.sh diff --git a/.claude/hooks/check-pr-green.sh b/.claude/hooks/check-pr-green.sh deleted file mode 100755 index 9593432..0000000 --- a/.claude/hooks/check-pr-green.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# PreToolUse gate: only allow `gh pr merge` when the PR's checks are green. -# Stdin: hook JSON with .tool_input.command containing the gh invocation. -cmd=$(jq -r '.tool_input.command // empty') -# Merge target = first non-flag arg after "merge": a PR number, URL, or -# branch name (gh pr checks accepts all three). Empty = PR for current branch. -target=$(printf '%s' "$cmd" | sed -n 's/.*gh pr merge//p' | tr ' ' '\n' | grep -m1 -v -e '^-' -e '^$' || true) -if out=$(gh pr checks ${target:+"$target"} 2>&1); then - exit 0 -fi -jq -n --arg out "$out" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: ("PR checks are not green; fix CI before merging.\n" + $out)}}' diff --git a/.claude/settings.json b/.claude/settings.json index 133a63f..9303341 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,18 +3,6 @@ "ask": ["Bash(./prod.sh*)"] }, "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "if": "Bash(gh pr merge*)", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-pr-green.sh" - } - ] - } - ], "Stop": [ { "hooks": [ diff --git a/CLAUDE.md b/CLAUDE.md index 983fffe..0569d49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,21 +16,20 @@ local telegram-bot-api server (raises the upload limit to 2GB). ## Workflow Most quality gates are enforced mechanically (pre-commit: lint + gitleaks + -tests + coverage; pre-push: e2e; hooks: commit-before-stop, -green-CI-before-merge; GitHub push protection). The conventions hooks can't +tests + coverage; pre-push: e2e; Stop hook: commit before finishing; GitHub: +secret push protection, and branch protection on main requiring a PR with +green CI — no direct pushes, admins included). The conventions hooks can't enforce: -- Features go on a branch with a PR; trivial fixes (typos, config) may go - straight to main. +- EVERY change goes on a branch with a PR, however small. The owner reviews + and merges all PRs. - Every behavior change ships with tests covering it. If the coverage thresholds in bunfig.toml block you, write better tests — don't lower them. -- While developing non-trivial changes, run the pr-review-toolkit agents that - match the work (silent-failure-hunter for error handling, pr-test-analyzer - for test quality, type-design-analyzer for new types, code-simplifier after - a big chunk). Fix the findings you agree with; dismiss bad ones with stated - reasoning. -- Before handing a PR to the owner, run /code-review --fix as the final - precision pass. Never post review comments on your own PR. +- Every change gets the full review treatment (the skills are fast on small + changes): /pr-review-toolkit:review-pr during development, then + /code-review --fix before handing the PR to the owner. Fix the findings you + agree with, dismiss bad ones with stated reasoning, and never post review + comments on your own PR. - e2e snapshot mismatches where only yt-dlp format ids / filenames changed are staleness, not bugs: refresh with `docker compose run --rm -T test bash -c "TEST_E2E=true bun --config=bunfig.e2e.toml test e2e -u"` From 679b25dbe4c13ce68376f2501e752fa0818c00e3 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Wed, 10 Jun 2026 23:22:24 +0200 Subject: [PATCH 11/12] CLAUDE.md: reviews finish before the PR opens, not after Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0569d49..830c677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,11 +25,12 @@ enforce: and merges all PRs. - Every behavior change ships with tests covering it. If the coverage thresholds in bunfig.toml block you, write better tests — don't lower them. -- Every change gets the full review treatment (the skills are fast on small - changes): /pr-review-toolkit:review-pr during development, then - /code-review --fix before handing the PR to the owner. Fix the findings you - agree with, dismiss bad ones with stated reasoning, and never post review - comments on your own PR. +- Every change gets the full review treatment BEFORE the PR is opened (the + skills are fast on small changes): /pr-review-toolkit:review-pr while + developing, /code-review --fix when the branch feels done, and only then + raise the PR — it should contain finished, already-reviewed code. Fix the + findings you agree with, dismiss bad ones with stated reasoning, and never + post review comments on your own PR. - e2e snapshot mismatches where only yt-dlp format ids / filenames changed are staleness, not bugs: refresh with `docker compose run --rm -T test bash -c "TEST_E2E=true bun --config=bunfig.e2e.toml test e2e -u"` From 9955089edc503ad345a8f842e127f471a17be3b3 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Thu, 11 Jun 2026 00:46:31 +0200 Subject: [PATCH 12/12] Make e2e snapshots immune to yt-dlp format drift; restore youtube e2e - scrub volatile yt-dlp output (format ids, sizes, bitrates) at snapshot time; mock file_id hashes a format-normalized name so cache snapshots don't drift either; shared FORMAT_ID_RE keeps the two in lockstep - mock rejects empty files like the real bot-api (catches truncated downloads that were previously invisible) - youtube runs in full e2e mode only (./e2e.sh full, used by the deploy gate); pre-push runs the reduced set. ./e2e.sh -u refreshes snapshots and implies full, since a reduced -u run would prune them - CLAUDE.md cut to the bare minimum Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 59 ++------ e2e.sh | 18 ++- prod.sh | 2 +- test/__snapshots__/e2e.test.ts.snap | 205 ++++++++++++++++++++++------ test/e2e.test.ts | 26 +++- test/simulate-bot-api.test.ts | 21 +++ test/simulate-bot-api.ts | 16 ++- 7 files changed, 250 insertions(+), 97 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 830c677..40e04c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,49 +1,14 @@ # mp4ify-bot -Telegram bot (@mp4ify_bot): send it a video link, it downloads with yt-dlp and -replies with the mp4. Bun + Telegraf, deployed via Docker Compose alongside a -local telegram-bot-api server (raises the upload limit to 2GB). - -## Commands - -- Test: `UID=$(id -u) GID=$(id -g) docker compose run --rm --no-deps test bun test` - (host runs fail: /storage is root-owned; tests must run in the test container) -- Full quality gate (lint + secret scan + tests): `./check.sh` — same thing the - pre-commit hook runs -- Deploy: `./prod.sh` (e2e-gated). Owner's call only — never deploy unprompted. -- Dev bot with hot reload: `./dev.sh` - -## Workflow - -Most quality gates are enforced mechanically (pre-commit: lint + gitleaks + -tests + coverage; pre-push: e2e; Stop hook: commit before finishing; GitHub: -secret push protection, and branch protection on main requiring a PR with -green CI — no direct pushes, admins included). The conventions hooks can't -enforce: - -- EVERY change goes on a branch with a PR, however small. The owner reviews - and merges all PRs. -- Every behavior change ships with tests covering it. If the coverage - thresholds in bunfig.toml block you, write better tests — don't lower them. -- Every change gets the full review treatment BEFORE the PR is opened (the - skills are fast on small changes): /pr-review-toolkit:review-pr while - developing, /code-review --fix when the branch feels done, and only then - raise the PR — it should contain finished, already-reviewed code. Fix the - findings you agree with, dismiss bad ones with stated reasoning, and never - post review comments on your own PR. -- e2e snapshot mismatches where only yt-dlp format ids / filenames changed are - staleness, not bugs: refresh with - `docker compose run --rm -T test bash -c "TEST_E2E=true bun --config=bunfig.e2e.toml test e2e -u"` - -## Gotchas - -- X/Twitter is intentionally unsupported for moral reasons. Do not add support. -- yt-dlp self-updates at startup and daily (world-writable /opt/yt-dlp in the - containers), so the version baked into the image is only a starting point. -- Verify Telegram API behavior empirically: capture real payloads, keep - MockBotApi in parity (test/simulate-bot-api.ts). The official docs are - incomplete or wrong in places. -- reddit/youtube hard-block datacenter IPs (403 / "account authentication - required"), so anything involving real yt-dlp downloads only works from - residential IPs. This is why e2e runs pre-push and at deploy but NOT in CI, - and why this project can't be developed from cloud environments. +Telegram bot (@mp4ify_bot): send it a video link, it replies with the mp4. +Bun + Telegraf + yt-dlp, deployed via Docker Compose with a local +telegram-bot-api server. + +- Tests only work inside the test container (/storage is root-owned on the + host): + `UID=$(id -u) GID=$(id -g) docker compose run --rm --no-deps test bun test` +- Everything goes on a branch + PR (main is protected). Before opening the + PR, run /pr-review-toolkit:review-pr, then /code-review --fix. +- Never assume Telegram API behavior from the docs — verify against real + payloads and keep MockBotApi (test/simulate-bot-api.ts) in parity. +- X/Twitter is deliberately unsupported, for moral reasons. Do not add it. diff --git a/e2e.sh b/e2e.sh index 76a5088..407ecc1 100755 --- a/e2e.sh +++ b/e2e.sh @@ -1,2 +1,18 @@ #!/bin/sh -docker compose run --remove-orphans --rm -T test bash -c "TEST_E2E=true bun --config=bunfig.e2e.toml test e2e" +# Usage: ./e2e.sh [full] [-u] +# full also test rate-limit-prone sites (youtube rejects more than a few +# hits per hour). Used by the deploy gate (prod.sh); the pre-push +# hook runs the reduced set. +# -u refresh snapshots. Implies full: `bun test -u` deletes snapshots of +# tests it didn't run, so a reduced -u run would silently prune the +# full-mode snapshots. +FULL="" +UPDATE="" +for arg in "$@"; do + case "$arg" in + full) FULL=1 ;; + -u) UPDATE="-u"; FULL=1 ;; + *) echo "unknown argument: $arg" >&2 && exit 64 ;; + esac +done +docker compose run --remove-orphans --rm -T -e TEST_E2E=true -e TEST_E2E_FULL="$FULL" test bun --config=bunfig.e2e.toml test e2e $UPDATE diff --git a/prod.sh b/prod.sh index fe12b1b..e281539 100755 --- a/prod.sh +++ b/prod.sh @@ -1,2 +1,2 @@ #!/bin/sh -./e2e.sh && UID=$(id -u) GID=$(id -g) docker compose up prod bot-api --build -d +./e2e.sh full && UID=$(id -u) GID=$(id -g) docker compose up prod bot-api --build -d diff --git a/test/__snapshots__/e2e.test.ts.snap b/test/__snapshots__/e2e.test.ts.snap index a34b347..e57c281 100644 --- a/test/__snapshots__/e2e.test.ts.snap +++ b/test/__snapshots__/e2e.test.ts.snap @@ -18,13 +18,13 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i 🎬 Video info: URL: https://www.instagram.com/reel/DKbYQgeoL3F/?igsh=MTh4MnpnYm9hdjJ5OA== -filename: Video_by_disastra_memes-[DKbYQgeoL3F].5.mp4 +filename: Video_by_disastra_memes-[DKbYQgeoL3F]..mp4 duration: 12 sec resolution: 640x584 ⬇️ Downloading... -🚀 Uploading (0.41 MB)..." +🚀 Uploading ( MB)..." , }, { @@ -37,7 +37,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i }, "reply_to_message_id": 0, "supports_streaming": true, - "video": "file:///storage/Instagram/Video_by_disastra_memes-%5BDKbYQgeoL3F%5D.5.mp4", + "video": "file:///storage/Instagram/Video_by_disastra_memes-%5BDKbYQgeoL3F%5D..mp4", "width": 640, }, ] @@ -60,7 +60,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i 🎬 Video info: URL: https://www.instagram.com/reel/DKbYQgeoL3F/?igsh=MTh4MnpnYm9hdjJ5OA== -filename: Video_by_disastra_memes-[DKbYQgeoL3F].5.mp4 +filename: Video_by_disastra_memes-[DKbYQgeoL3F]..mp4 duration: 12 sec resolution: 640x584" , @@ -75,7 +75,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i }, "reply_to_message_id": 1, "supports_streaming": true, - "video": "4zkom5l8ldh1", + "video": "if2kn4n54iuj", "width": 640, }, ] @@ -98,7 +98,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i 🎬 Video info: URL: https://www.instagram.com/reel/DKbYQgeoL3F/?igsh=MTh4MnpnYm9hdjJ5OA== -filename: Video_by_disastra_memes-[DKbYQgeoL3F].5.mp4 +filename: Video_by_disastra_memes-[DKbYQgeoL3F]..mp4 duration: 12 sec resolution: 640x584" , @@ -113,7 +113,7 @@ exports[`message handler downloads https://www.instagram.com/reel/DKbYQgeoL3F/?i }, "reply_to_message_id": 2, "supports_streaming": true, - "video": "4zkom5l8ldh1", + "video": "if2kn4n54iuj", "width": 640, }, ] @@ -137,16 +137,16 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps ⬇️ Downloading... -🚀 Uploading (1.20 MB)..." +🚀 Uploading ( MB)..." , }, { @@ -159,7 +159,7 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i }, "reply_to_message_id": 0, "supports_streaming": true, - "video": "file:///storage/Reddit/Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-%5Bauhgg4yoeo5f1%5D.hls-1023+dash-6.mp4", + "video": "file:///storage/Reddit/Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-%5Bauhgg4yoeo5f1%5D..mp4", "width": 480, }, ] @@ -182,12 +182,12 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps" +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps" , }, { @@ -200,7 +200,7 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i }, "reply_to_message_id": 1, "supports_streaming": true, - "video": "uufngxsbt7v5", + "video": "k73nlloqf5d1", "width": 480, }, ] @@ -223,12 +223,12 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo&utm_content=2&utm_medium=android_app&utm_name=androidcss&utm_source=share&utm_term=1 -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps" +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps" , }, { @@ -241,7 +241,7 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/s/i }, "reply_to_message_id": 2, "supports_streaming": true, - "video": "uufngxsbt7v5", + "video": "k73nlloqf5d1", "width": 480, }, ] @@ -265,16 +265,16 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps ⬇️ Downloading... -🚀 Uploading (1.20 MB)..." +🚀 Uploading ( MB)..." , }, { @@ -287,7 +287,7 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com }, "reply_to_message_id": 0, "supports_streaming": true, - "video": "file:///storage/Reddit/Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-%5Bauhgg4yoeo5f1%5D.hls-1023+dash-6.mp4", + "video": "file:///storage/Reddit/Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-%5Bauhgg4yoeo5f1%5D..mp4", "width": 480, }, ] @@ -310,12 +310,12 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps" +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps" , }, { @@ -328,7 +328,7 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com }, "reply_to_message_id": 1, "supports_streaming": true, - "video": "uufngxsbt7v5", + "video": "k73nlloqf5d1", "width": 480, }, ] @@ -351,12 +351,12 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com 🎬 Video info: URL: https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo -filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1].hls-1023+dash-6.mp4 +filename: Mix_of_coolness_agility_technique_power_and_a_touch_of_madness-[auhgg4yoeo5f1]..mp4 duration: 10 sec -size: 0.01 MB +size: MB resolution: 480x600 -video codec: avc1.4D401F @ 1023.275 kbps -audio codec: mp4a.40.2 @ 132.978 kbps" +video codec: avc1.4D401F @ kbps +audio codec: mp4a.40.2 @ kbps" , }, { @@ -369,9 +369,136 @@ exports[`message handler downloads https://www.reddit.com/r/nextfuckinglevel/com }, "reply_to_message_id": 2, "supports_streaming": true, - "video": "uufngxsbt7v5", + "video": "k73nlloqf5d1", "width": 480, }, ] `; +exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: download 1`] = ` +[ + { + "chat_id": 1337, + "disable_notification": true, + "link_preview_options": { + "is_disabled": true, + }, + "parse_mode": "HTML", + "reply_parameters": { + "message_id": 0, + }, + "text": +"🧐 Scraping http://youtube.com/shorts/0COu-qMC18Y... + +🎬 Video info: + +URL: https://www.youtube.com/watch?v=0COu-qMC18Y +filename: Zinger_Burgers-[0COu-qMC18Y]..mp4 +duration: 57 sec +size: MB +resolution: 1080x1920 +video codec: avc1.64002a @ kbps +audio codec: mp4a.40.2 @ kbps + +⬇️ Downloading... + +🚀 Uploading ( MB)..." +, + }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 57, + "height": 1920, + "reply_parameters": { + "message_id": 0, + }, + "reply_to_message_id": 0, + "supports_streaming": true, + "video": "file:///storage/youtube/Zinger_Burgers-%5B0COu-qMC18Y%5D..mp4", + "width": 1080, + }, +] +`; + +exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: mem cache 1`] = ` +[ + { + "chat_id": 1337, + "disable_notification": true, + "link_preview_options": { + "is_disabled": true, + }, + "parse_mode": "HTML", + "reply_parameters": { + "message_id": 1, + }, + "text": +" +🎬 Video info: + +URL: https://www.youtube.com/watch?v=0COu-qMC18Y +filename: Zinger_Burgers-[0COu-qMC18Y]..mp4 +duration: 57 sec +size: MB +resolution: 1080x1920 +video codec: avc1.64002a @ kbps +audio codec: mp4a.40.2 @ kbps" +, + }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 57, + "height": 1920, + "reply_parameters": { + "message_id": 1, + }, + "reply_to_message_id": 1, + "supports_streaming": true, + "video": "3f0qpzdt5nrk4", + "width": 1080, + }, +] +`; + +exports[`message handler downloads http://youtube.com/shorts/0COu-qMC18Y: disk cache 1`] = ` +[ + { + "chat_id": 1337, + "disable_notification": true, + "link_preview_options": { + "is_disabled": true, + }, + "parse_mode": "HTML", + "reply_parameters": { + "message_id": 2, + }, + "text": +" +🎬 Video info: + +URL: https://www.youtube.com/watch?v=0COu-qMC18Y +filename: Zinger_Burgers-[0COu-qMC18Y]..mp4 +duration: 57 sec +size: MB +resolution: 1080x1920 +video codec: avc1.64002a @ kbps +audio codec: mp4a.40.2 @ kbps" +, + }, + { + "chat_id": 1337, + "disable_notification": true, + "duration": 57, + "height": 1920, + "reply_parameters": { + "message_id": 2, + }, + "reply_to_message_id": 2, + "supports_streaming": true, + "video": "3f0qpzdt5nrk4", + "width": 1080, + }, +] +`; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 66edc77..c540eb1 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -9,7 +9,7 @@ import { mock, } from 'bun:test'; import { downloadVideo, getInfo, sendVideo } from '../src/download-video'; -import { withBotApi } from './simulate-bot-api'; +import { FORMAT_ID_RE, withBotApi } from './simulate-bot-api'; import { spyMock, waitUntil } from './test-utils'; beforeEach(() => jest.clearAllMocks()); @@ -43,13 +43,25 @@ const testUrls = [ 'https://www.reddit.com/r/nextfuckinglevel/s/iGEii0a7V6', // canonical url for same video 'https://www.reddit.com/r/nextfuckinglevel/comments/1l68isw/mix_of_coolness_agility_technique_power_and_a/?share_id=ejTJZnh_f4BZuzlnfcOUo', - // NO youtube url: it rate-limits aggressively ("Sign in to confirm you're - // not a bot") when the suite runs more than a few times per hour, which a - // pre-push gate does. See 571670e for the first time we learned this. + // only in full mode - see e2e.sh for the modes and why + ...(Bun.env.TEST_E2E_FULL ? ['http://youtube.com/shorts/0COu-qMC18Y'] : []), ]; const clearDiskCache = async () => $`rm -rf /storage/*`.catch(() => null); +// yt-dlp's format selection shifts as sites change their offerings, which +// changes format ids in filenames, sizes, and bitrates without any change in +// bot behavior. Scrub the most volatile of those. NOT scrubbed (and still +// snapshot-breaking if the chosen format changes shape): codec profile +// strings, resolution, and duration - those are real signal. +const scrub = (messages: unknown) => + JSON.parse( + JSON.stringify(messages) + .replaceAll(FORMAT_ID_RE, '$1.$2') + .replaceAll(/\d+(\.\d+)? MB/g, ' MB') + .replaceAll(/@ \d+(\.\d+)? kbps/g, '@ kbps'), + ); + const clearInMemoryCache = () => { getInfo.cache.clear(); downloadVideo.cache.clear(); @@ -85,20 +97,20 @@ describe.if(!!Bun.env.TEST_E2E)('message handler', async () => { clearInMemoryCache(); api.sendTextMessageToBot(urlMessage(url)); await waitForVideo(25_000); - expect(api.sentMessages).toMatchSnapshot('download'); + expect(scrub(api.sentMessages)).toMatchSnapshot('download'); // in memory cache api.sentMessages.length = 0; api.sendTextMessageToBot(urlMessage(url)); await waitForVideo(5_000); - expect(api.sentMessages).toMatchSnapshot('mem cache'); + expect(scrub(api.sentMessages)).toMatchSnapshot('mem cache'); // disk cache clearInMemoryCache(); api.sentMessages.length = 0; api.sendTextMessageToBot(urlMessage(url)); await waitForVideo(5_000); - expect(api.sentMessages).toMatchSnapshot('disk cache'); + expect(scrub(api.sentMessages)).toMatchSnapshot('disk cache'); }), 40_000, ); diff --git a/test/simulate-bot-api.test.ts b/test/simulate-bot-api.test.ts index 224c991..d4fc510 100644 --- a/test/simulate-bot-api.test.ts +++ b/test/simulate-bot-api.test.ts @@ -177,6 +177,7 @@ describe('MockBotApi', () => { //@ts-ignore spyOn(Bun, 'file').mockReturnValue({ exists: mock().mockResolvedValue(true), + size: 1, }); const url = new URL(`${apiRoot}/bot${api.botToken}/sendVideo`); const body = JSON.stringify({ @@ -192,6 +193,26 @@ describe('MockBotApi', () => { expect(json.result.video.file_name).toBe('/tmp/real.mp4'); }); + it('handle sendVideo returns error if file is empty', async () => { + //@ts-ignore + spyOn(Bun, 'file').mockReturnValue({ + exists: mock().mockResolvedValue(true), + size: 0, + }); + const url = new URL(`${apiRoot}/bot${api.botToken}/sendVideo`); + const body = JSON.stringify({ + chat_id: api['user'].id, + video: Bun.pathToFileURL('/tmp/empty.mp4'), + width: 1, + height: 1, + duration: 1, + }); + const resp = (await api.handle(url, { method: 'POST', body })) as Response; + const json = await resp.json(); + expect(json.ok).toBe(false); + expect(json.description).toMatch(/file is empty/); + }); + it('handle unknown command throws', () => { const url = new URL(`${apiRoot}/bot${api.botToken}/unknownCommand`); expect(() => api.handle(url, { method: 'POST', body: '{}' })).toThrow( diff --git a/test/simulate-bot-api.ts b/test/simulate-bot-api.ts index 165766a..c4ab765 100644 --- a/test/simulate-bot-api.ts +++ b/test/simulate-bot-api.ts @@ -14,6 +14,11 @@ import { apiRoot } from '../src/consts'; // we should probably consolidate final state of edited messages // The only part we intercept is where it asks for updates. +// matches the `.{format_id}.` filename segment yt-dlp produces (plain or +// URL-encoded `]` before it). Shared by the e2e snapshot scrubber and the +// mock's file_id hashing so the two can't drift apart. +export const FORMAT_ID_RE = /(\]|%5D)\.[\w+-]+(\.mp4)/g; + const okResp = (result: any, description?: string) => new Response( JSON.stringify({ ok: true, result, ...(description && { description }) }), @@ -288,10 +293,17 @@ export class MockBotApi { file_id = video; } else { file_name = Bun.fileURLToPath(video); - if (!(await Bun.file(file_name).exists())) { + const file = Bun.file(file_name); + if (!(await file.exists())) { return errResp(`Bad Request: file not found: ${file_name}`); } - file_id = Bun.hash(video).toString(36); + // the real bot-api rejects empty uploads; catches truncated downloads + if (file.size === 0) { + return errResp(`Bad Request: file is empty: ${file_name}`); + } + // hash a format-normalized name so the file_id (pinned in e2e + // snapshots) stays stable when yt-dlp's format selection drifts + file_id = Bun.hash(video.replaceAll(FORMAT_ID_RE, '$1$2')).toString(36); this.fileIds.set(file_id, file_name); } const message = {