Skip to content

Instantly share code, notes, and snippets.

@ColeMurray
Last active August 21, 2026 19:09
Show Gist options
  • Select an option

  • Save ColeMurray/2721f996886b2101387780faa33c62b1 to your computer and use it in GitHub Desktop.

Select an option

Save ColeMurray/2721f996886b2101387780faa33c62b1 to your computer and use it in GitHub Desktop.
mark all tests as viewed
// ==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