Last active
August 21, 2026 19:09
-
-
Save ColeMurray/2721f996886b2101387780faa33c62b1 to your computer and use it in GitHub Desktop.
mark all tests as viewed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name GitHub PR: Mark Test Files as Viewed | |
| // @namespace gh-mark-tests-viewed | |
| // @version 1.2.0 | |
| // @description Adds a button to GitHub PR "Files changed" pages that automatically clicks "Viewed" on every test file. Works across common test patterns (JS/TS, Python, Go, Ruby, Java, .NET, PHP, C/C++, Rust, and more) and both the classic and new PR files UIs. | |
| // NOTE: matches all PR tabs (not just /files) on purpose — GitHub uses soft | |
| // navigations, so the script must already be loaded when you click the | |
| // "Files changed" tab. The script gates itself on the URL internally. | |
| // @match https://github.com/*/*/pull/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @license MIT | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| if (window.__ghMarkTestsViewedLoaded) return; | |
| window.__ghMarkTestsViewedLoaded = true; | |
| const VERSION = '1.2.0'; | |
| /* ------------------------------------------------------------------ * | |
| * Configuration | |
| * ------------------------------------------------------------------ */ | |
| // If true, test files are marked as viewed automatically (no button | |
| // click needed), including files that lazy-load as you scroll. | |
| const AUTO_MARK_ON_LOAD = false; | |
| // Delay between individual "Viewed" clicks (GitHub fires one request | |
| // per file; a small delay avoids hammering the API on large PRs). | |
| const CLICK_DELAY_MS = 100; | |
| /** | |
| * Repo-relative paths (e.g. "src/utils/__tests__/sum.test.ts") are | |
| * tested against this list. Add/remove entries to taste. | |
| * | |
| * Note: patterns are intentionally case-sensitive where the ecosystem | |
| * convention is ("FooTest.java", "foo_test.go") to avoid false | |
| * positives like "Latest.java" or "attest.go". | |
| */ | |
| const TEST_FILE_PATTERNS = [ | |
| // --- Test directories (matched at any depth) ----------------------- | |
| /(^|\/)(__tests__|__mocks__|__snapshots__)\//, // Jest conventions | |
| /(^|\/)(tests?|spec|e2e|cypress|testdata|fixtures?)\//i, // test/, tests/, spec/, e2e/, cypress/, testdata/ (Go), fixture(s)/ | |
| // --- JavaScript / TypeScript -------------------------------------- | |
| /\.(test|spec|cy|e2e-spec|e2e)\.[cm]?[jt]sx?$/, // foo.test.ts, foo.spec.jsx, foo.cy.ts (Cypress), foo.e2e-spec.ts | |
| /[^/]*_(?:test|spec)\.[cm]?[jt]sx?$/, // foo_test.ts, foo_spec.js (Deno / underscore conventions) | |
| /(^|\/)test_[^/]*\.[cm]?[jt]sx?$/, // test_foo.ts | |
| /\.(test|spec)-d\.ts$/, // foo.test-d.ts (vitest type tests) | |
| // --- Python --------------------------------------------------------- | |
| /(^|\/)test_[^/]*\.py$/, // test_foo.py | |
| /(^|\/)[^/]*_test\.py$/, // foo_test.py | |
| /(^|\/)conftest\.py$/, // pytest fixtures | |
| // --- Go ------------------------------------------------------------- | |
| /_test\.go$/, // foo_test.go | |
| // --- Ruby ----------------------------------------------------------- | |
| /[^/]*_(spec|test)\.rb$/, // foo_spec.rb, foo_test.rb | |
| // --- Java / Kotlin / Scala ----------------------------------------- | |
| /[^/]*(?:Test|Tests|TestCase|Spec|IT)\.(?:java|kt|kts|scala)$/, // FooTest.java, FooSpec.scala, FooIT.java (failsafe) | |
| // --- .NET ----------------------------------------------------------- | |
| /[^/]*Tests?\.(?:cs|fs|vb)$/, // FooTests.cs, FooTest.cs | |
| // --- PHP ------------------------------------------------------------ | |
| /[^/]*Test\.php$/, // FooTest.php (PHPUnit) | |
| // --- Swift ---------------------------------------------------------- | |
| /[^/]*Tests?\.swift$/, // FooTests.swift (XCTest) | |
| // --- C / C++ -------------------------------------------------------- | |
| /(^|\/)test_[^/]*\.(?:c|cc|cpp|cxx|h|hpp)$/, // test_foo.cc | |
| /[^/]*(?:_test|_unittest)\.(?:c|cc|cpp|cxx|h|hpp)$/, // foo_test.cc, foo_unittest.cc (gtest) | |
| // --- Elixir / Dart / Haskell / Lua ---------------------------------- | |
| /_test\.(?:exs|dart)$/, // foo_test.exs (ExUnit), foo_test.dart (flutter_test) | |
| /[^/]*Spec\.hs$/, // FooSpec.hs (Haskell) | |
| /(^|\/)(?:test|spec)_[^/]*\.lua$/, // test_foo.lua, spec_foo.lua (busted) | |
| // --- Shell ---------------------------------------------------------- | |
| /[^/]*\.bats$/, // foo.bats (Bash Automated Testing System) | |
| // --- Test tooling configs (uncomment if desired) -------------------- | |
| // /(?:jest|vitest|playwright|cypress|karma|ava)\.config\.[jt]s$/, | |
| ]; | |
| /* ------------------------------------------------------------------ * | |
| * Helpers | |
| * ------------------------------------------------------------------ */ | |
| const PANEL_ID = 'gh-mtv-panel'; | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| function debounce(fn, wait) { | |
| let t; | |
| return (...args) => { | |
| clearTimeout(t); | |
| t = setTimeout(() => fn(...args), wait); | |
| }; | |
| } | |
| function onPrFilesPage() { | |
| return /^\/[^/]+\/[^/]+\/pull\/\d+\/(?:files|changes)/.test(location.pathname); | |
| } | |
| function isTestFile(path) { | |
| return !!path && TEST_FILE_PATTERNS.some((re) => re.test(path)); | |
| } | |
| // GitHub embeds bidi control marks in some file names — strip them. | |
| function cleanPath(v) { | |
| return (v || '').replace(/[\u200e\u200f\u202a-\u202e]/g, '').trim(); | |
| } | |
| /* ------------------------------------------------------------------ * | |
| * GitHub DOM adapters. | |
| * | |
| * Discovery is "toggle-first": find every "Viewed" control on the page | |
| * (the accessible name is the most stable contract GitHub maintains), | |
| * then walk UP to the file container. Layout wrappers change often; | |
| * the a11y name of the viewed toggle does not. | |
| * ------------------------------------------------------------------ */ | |
| // Known per-file container shapes across GitHub's UI generations. | |
| const CONTAINER_SEL = | |
| '[id^="diff-"], [data-tagsearch-path], [role="region"], [class*="Diff-module__diffTargetable--"]'; | |
| function containerForToggle(el) { | |
| return el.closest(CONTAINER_SEL); | |
| } | |
| /** All elements anywhere under `root` that could be a "Viewed" toggle. */ | |
| function collectToggleElements(root = document) { | |
| const els = root.querySelectorAll( | |
| 'input.js-reviewed-checkbox, button, [role="button"], [role="checkbox"], [role="switch"]' | |
| ); | |
| return [...els].filter((el) => { | |
| if (el.closest('#' + PANEL_ID)) return false; // ignore our own panel | |
| if (el.matches('input.js-reviewed-checkbox')) return true; | |
| const name = (el.getAttribute('aria-label') || el.textContent || '').trim(); | |
| return /\bviewed\b/i.test(name); | |
| }); | |
| } | |
| /** Path candidates for a container, best sources first. */ | |
| function getPathCandidates(el) { | |
| const out = []; | |
| const push = (v) => { | |
| v = cleanPath(v); | |
| if (v) out.push(v); | |
| }; | |
| push(el.getAttribute?.('data-tagsearch-path')); | |
| push(el.closest?.('copilot-diff-entry[data-file-path]')?.getAttribute('data-file-path')); | |
| push(el.querySelector?.('.file-header')?.getAttribute('data-path')); | |
| // New React UI: diff body grid carries the path in its accessible name. | |
| const grid = el.matches?.('[role="grid"][aria-label^="Diff for:"]') | |
| ? el | |
| : el.querySelector?.('[role="grid"][aria-label^="Diff for:"]'); | |
| if (grid) push(grid.getAttribute('aria-label').replace(/^Diff for:\s*/i, '')); | |
| // Header links: title attribute, then link text. | |
| const titled = el.querySelector?.('a[title][href^="#diff-"], a[title]'); | |
| if (titled) push(titled.getAttribute('title')); | |
| const anchor = el.querySelector?.('a[href^="#diff-"]'); | |
| if (anchor) push(anchor.textContent); | |
| // "View file" blob link: /blob/<ref>/<path> | |
| const blob = el.querySelector?.('a[href*="/blob/"]'); | |
| if (blob) { | |
| const m = blob.getAttribute('href').match(/\/blob\/[^/]+\/([^?#]+)/); | |
| if (m) push(decodeURIComponent(m[1])); | |
| } | |
| const clip = el.querySelector?.('clipboard-copy[value]'); | |
| if (clip && clip.getAttribute('value').includes('/')) push(clip.getAttribute('value')); | |
| // New React UI: region labelled by its file header. | |
| const region = el.matches?.('[role="region"][aria-labelledby]') | |
| ? el | |
| : el.querySelector?.('[role="region"][aria-labelledby]') || | |
| el.closest?.('[role="region"][aria-labelledby]'); | |
| if (region) { | |
| const header = document.getElementById(region.getAttribute('aria-labelledby')); | |
| if (header) { | |
| push(header.textContent); | |
| header.textContent.split(/\s+/).forEach(push); // tolerate badge/icon text around the path | |
| } | |
| } | |
| return out; | |
| } | |
| /** | |
| * Build a file entry for a toggle element. | |
| * Returns null when the viewed state can't be determined safely — | |
| * we never click a control we can't read, to avoid un-viewing files. | |
| */ | |
| function describeToggle(container, el) { | |
| // Classic UI: two forms (mark / unmark), exactly one visible (not .d-none). | |
| const unmark = container.querySelector('form.js-unmarkAsViewedForm:not(.d-none) input.js-reviewed-checkbox'); | |
| const mark = container.querySelector('form.js-markAsViewedForm:not(.d-none) input.js-reviewed-checkbox'); | |
| let isViewed; | |
| if (unmark || mark) { | |
| el = unmark || mark; | |
| isViewed = !!unmark; | |
| } else if (el.matches('input.js-reviewed-checkbox')) { | |
| isViewed = el.checked; | |
| } else { | |
| const pressed = el.getAttribute('aria-pressed'); | |
| const checked = el.getAttribute('aria-checked'); | |
| if (pressed === null && checked === null) return null; // state unknown → don't touch | |
| isViewed = pressed === 'true' || checked === 'true'; | |
| } | |
| const paths = getPathCandidates(container); | |
| return { | |
| container, | |
| el, | |
| isViewed, | |
| paths, | |
| path: paths[0] || null, | |
| isTest: paths.some(isTestFile), | |
| }; | |
| } | |
| /** | |
| * Scan the page for files. Results are deduplicated by path (falling | |
| * back to the container element) because the same file can expose a | |
| * "Viewed" control in more than one place (header, file tree, ...). | |
| */ | |
| function scanFiles() { | |
| const byKey = new Map(); | |
| for (const el of collectToggleElements()) { | |
| const container = containerForToggle(el); | |
| if (!container) continue; // e.g. the "N files viewed" progress — not a file | |
| const info = describeToggle(container, el); | |
| if (!info) continue; | |
| const key = info.path || container; | |
| if (!byKey.has(key)) byKey.set(key, info); | |
| } | |
| return [...byKey.values()]; | |
| } | |
| /* ------------------------------------------------------------------ * | |
| * Mark files as viewed | |
| * ------------------------------------------------------------------ */ | |
| let running = false; | |
| async function markAllTestFiles() { | |
| if (running) return; | |
| running = true; | |
| update(); | |
| try { | |
| for (const item of scanFiles()) { | |
| if (!item.isTest || item.isViewed) continue; | |
| // Re-read state: GitHub re-renders toggles after each click. | |
| const fresh = describeToggle(item.container, item.el); | |
| if (fresh && !fresh.isViewed) { | |
| fresh.el.click(); | |
| await sleep(CLICK_DELAY_MS); | |
| } | |
| } | |
| } finally { | |
| running = false; | |
| setTimeout(update, 500); | |
| } | |
| } | |
| /* ------------------------------------------------------------------ * | |
| * Diagnostics — open DevTools and run __ghMtvDebug() (or use the | |
| * panel's Debug button) to get a copyable report of what was found. | |
| * ------------------------------------------------------------------ */ | |
| function debugInfo() { | |
| const counts = {}; | |
| for (const [label, sel] of Object.entries({ | |
| 'containers[id^=diff-]': '[id^="diff-"]', | |
| 'containers[data-tagsearch-path]': '[data-tagsearch-path]', | |
| 'containers[role=region]': '[role="region"]', | |
| 'containers[Diff-module]': '[class*="Diff-module__diffTargetable--"]', | |
| 'toggles[js-reviewed-checkbox]': 'input.js-reviewed-checkbox', | |
| 'toggles[button aria-label*=Viewed]': 'button[aria-label*="Viewed"]', | |
| 'toggles[aria-checked]': '[aria-checked]', | |
| 'toggles[aria-pressed]': '[aria-pressed]', | |
| 'toggles[all detected]': null, | |
| })) { | |
| counts[label] = sel ? document.querySelectorAll(sel).length : collectToggleElements().length; | |
| } | |
| const files = scanFiles(); | |
| return { | |
| script: `gh-mark-tests-viewed v${VERSION}`, | |
| url: location.href, | |
| onPrFilesPage: onPrFilesPage(), | |
| counts, | |
| totals: { | |
| files: files.length, | |
| testFiles: files.filter((f) => f.isTest).length, | |
| unviewedTestFiles: files.filter((f) => f.isTest && !f.isViewed).length, | |
| }, | |
| files: files.slice(0, 10).map((f) => ({ | |
| path: f.path, | |
| isTest: f.isTest, | |
| isViewed: f.isViewed, | |
| toggle: | |
| f.el.tagName.toLowerCase() + | |
| (f.el.getAttribute('aria-label') ? `[aria-label="${f.el.getAttribute('aria-label')}"]` : '') + | |
| (f.el.getAttribute('aria-pressed') !== null ? `[aria-pressed="${f.el.getAttribute('aria-pressed')}"]` : '') + | |
| (f.el.getAttribute('aria-checked') !== null ? `[aria-checked="${f.el.getAttribute('aria-checked')}"]` : ''), | |
| containerSnippet: f.container.outerHTML.slice(0, 300), | |
| })), | |
| }; | |
| } | |
| window.__ghMtvDebug = () => { | |
| const report = debugInfo(); | |
| console.log('[mark-tests-viewed] debug report — copy this object and share it:', report); | |
| return report; | |
| }; | |
| /* ------------------------------------------------------------------ * | |
| * Floating panel UI | |
| * ------------------------------------------------------------------ */ | |
| function ensurePanel() { | |
| let panel = document.getElementById(PANEL_ID); | |
| if (panel) return panel; | |
| panel = document.createElement('div'); | |
| panel.id = PANEL_ID; | |
| panel.style.cssText = [ | |
| 'position:fixed', 'right:16px', 'bottom:16px', 'z-index:9999', | |
| 'display:flex', 'flex-direction:column', 'gap:6px', 'align-items:flex-end', | |
| 'padding:10px 12px', | |
| 'border:1px solid var(--borderColor-default,#d1d9e0)', 'border-radius:8px', | |
| 'background:var(--bgColor-default,#fff)', | |
| 'box-shadow:0 8px 24px rgba(140,149,159,.2)', | |
| 'font:12px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', | |
| ].join(';'); | |
| const stats = document.createElement('span'); | |
| stats.id = PANEL_ID + '-stats'; | |
| stats.style.color = 'var(--fgColor-muted,#59636e)'; | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = 'btn btn-sm btn-primary'; | |
| btn.id = PANEL_ID + '-btn'; | |
| btn.addEventListener('click', () => markAllTestFiles()); | |
| const debug = document.createElement('button'); | |
| debug.type = 'button'; | |
| debug.className = 'btn-link'; | |
| debug.id = PANEL_ID + '-debug'; | |
| debug.style.cssText = 'font-size:11px;color:var(--fgColor-muted,#59636e);padding:0'; | |
| debug.textContent = 'debug'; | |
| debug.title = 'Log + copy a diagnostic report (share it when reporting issues)'; | |
| debug.addEventListener('click', () => { | |
| const report = debugInfo(); | |
| console.log('[mark-tests-viewed] debug report:', report); | |
| debug.textContent = 'debug (logged to console)'; | |
| navigator.clipboard | |
| ?.writeText(JSON.stringify(report, null, 2)) | |
| .then(() => (debug.textContent = 'debug (copied!)')) | |
| .catch(() => {}); | |
| setTimeout(() => (debug.textContent = 'debug'), 3000); | |
| }); | |
| panel.append(stats, btn, debug); | |
| document.body.appendChild(panel); | |
| return panel; | |
| } | |
| let lastScanSig = null; | |
| function update() { | |
| if (!onPrFilesPage() || !document.body) { | |
| document.getElementById(PANEL_ID)?.remove(); | |
| lastScanSig = null; | |
| return; | |
| } | |
| const files = scanFiles(); | |
| const tests = files.filter((f) => f.isTest); | |
| const unviewed = tests.filter((f) => !f.isViewed); | |
| // Log scan results whenever they change (helps diagnosis in DevTools). | |
| const sig = `${files.length}|${tests.length}|${unviewed.length}`; | |
| if (sig !== lastScanSig) { | |
| lastScanSig = sig; | |
| console.info( | |
| '[mark-tests-viewed]', | |
| `files detected: ${files.length}, test files: ${tests.length}, unviewed test files: ${unviewed.length}` | |
| ); | |
| } | |
| const panel = ensurePanel(); | |
| const stats = panel.querySelector('#' + PANEL_ID + '-stats'); | |
| const btn = panel.querySelector('#' + PANEL_ID + '-btn'); | |
| if (!files.length) { | |
| stats.textContent = 'No files detected yet — diffs still loading, unknown page layout, or logged out?'; | |
| btn.disabled = true; | |
| btn.textContent = 'Mark test files as viewed'; | |
| return; | |
| } | |
| stats.textContent = `Test files viewed: ${tests.length - unviewed.length}/${tests.length}`; | |
| btn.disabled = running || unviewed.length === 0; | |
| btn.textContent = running | |
| ? 'Marking test files…' | |
| : unviewed.length | |
| ? `Mark ${unviewed.length} test file${unviewed.length > 1 ? 's' : ''} as viewed` | |
| : 'All test files viewed ✓'; | |
| if (AUTO_MARK_ON_LOAD && unviewed.length) markAllTestFiles(); | |
| } | |
| /* ------------------------------------------------------------------ * | |
| * Boot + keep-alive across Turbo navigations & progressive diff loads | |
| * ------------------------------------------------------------------ */ | |
| const debouncedUpdate = debounce(update, 300); | |
| // Observe <html>: Turbo can replace <body>, which would kill a body-level observer. | |
| new MutationObserver(debouncedUpdate).observe(document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| document.addEventListener('turbo:render', debouncedUpdate); | |
| document.addEventListener('turbo:load', debouncedUpdate); | |
| document.addEventListener('pjax:end', debouncedUpdate); | |
| update(); | |
| console.info(`[mark-tests-viewed] v${VERSION} loaded — run __ghMtvDebug() in the console for diagnostics`); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment