Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/workflows/selector-health.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Selector Health

on:
schedule:
- cron: '0 9 * * 1'
workflow_dispatch:
push:
branches: [main]
paths:
- 'tools/selector-health/**'
- '.github/workflows/selector-health.yml'

permissions:
contents: read
issues: write

jobs:
check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: tools/selector-health
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: tools/selector-health/package-lock.json

- run: npm ci

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-1.59.1

- run: npx playwright install --with-deps chromium

- id: run-check
run: npm run check
continue-on-error: true

- uses: actions/upload-artifact@v4
if: always()
with:
name: selector-health-report
path: tools/selector-health/report.json
retention-days: 90

- name: Open or update tracking issue on failure
if: steps.run-check.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
TITLE="Selector health check failing"
BODY_FILE="$(mktemp)"
{
echo "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo
echo "## Missing selectors"
echo
jq -r '
.results[]
| select(.found == false)
| "- **\(.id)** (`\(.testid)`) [\(.criticality)] -- used by: \((.scripts | join(", ")) // "(none)")"
' tools/selector-health/report.json
echo
echo "<details><summary>Full report</summary>"
echo
echo '```json'
cat tools/selector-health/report.json
echo '```'
echo
echo "</details>"
} > "$BODY_FILE"

NUM=$(gh issue list --state open --search "$TITLE in:title" --json number,title \
--jq ".[] | select(.title == \"$TITLE\") | .number" | head -n1)

if [ -z "$NUM" ]; then
gh issue create --title "$TITLE" --label "bug" --body-file "$BODY_FILE"
else
gh issue edit "$NUM" --body-file "$BODY_FILE"
gh issue comment "$NUM" --body "Re-run on $(date -u +%FT%TZ): ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
fi

- name: Mark job failed if check failed
if: steps.run-check.outcome == 'failure'
run: exit 1
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
tools/selector-health/report.json
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
- Namespace: `https://github.com/digitalby`

## Twitter/X DOM Patterns
The selectors below are mirrored in `tools/selector-health/selectors.json` and verified weekly by the `Selector Health` GitHub Actions workflow. When you add, rename, or remove a selector here, update that file in the same commit.

- Tweets: `article[data-testid="tweet"]`
- User name container: `[data-testid="User-Name"]`
- Avatar with handle: `[data-testid^="UserAvatar-Container-"]`
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Twitter/X Userscripts

[![Selector Health](https://github.com/digitalby/twitter-userscripts/actions/workflows/selector-health.yml/badge.svg)](https://github.com/digitalby/twitter-userscripts/actions/workflows/selector-health.yml)

A collection of userscripts that add keyboard shortcuts, inline information, and quality-of-life improvements to Twitter/X.

![Inline follower count and bio on a tweet](screenshots/follower-count.png)
Expand Down
36 changes: 36 additions & 0 deletions tools/selector-health/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Selector Health Check

CI canary that loads `https://x.com/jack/status/20` in a headless Chromium, captures every JavaScript bundle served during the page load, and greps them for proxy identifiers tied to the selectors the userscripts depend on. Public CDN bundles require no authentication, so this runs cleanly in GitHub Actions with no secrets.

If any critical or high-priority proxy falls below its minimum occurrence count, the check exits non-zero and CI opens (or updates) a rolling GitHub issue.

## What this catches vs what it misses

**Catches (strong signal):** Twitter renaming or removing a feature. Example: if `QuoteTweet` stops appearing in the bundle, the quote tweet component was removed or renamed, and our quote-hotkey script is about to break.

**Misses (blind spot):** A silent `data-testid` rename where the underlying component keeps its name but the attribute value changes (e.g., `data-testid="caret"` → `data-testid="moreMenu"`). Most React components don't embed their `data-testid` values as string literals in the bundle -- those are set dynamically via props -- so bundle grep can't see them.

This is an early-warning canary for structural changes, not a full integration test. A full test would require a logged-in session in CI, which we've chosen not to do. The canary is the automation we get for free; pairing it with user-reported breakage remains necessary.

## Run locally

```
cd tools/selector-health
npm ci
npx playwright install chromium
npm run check
```

Writes `report.json` next to the script.

## Updating the selector list

`selectors.json` mirrors the "Twitter/X DOM Patterns" section of the root `CLAUDE.md`. When you add a new selector to a userscript, update both files in the same commit.

## Exit codes

| Code | Meaning |
|---|---|
| 0 | All critical/high selectors present (medium misses are logged but non-blocking) |
| 1 | At least one critical or high selector is missing from the live bundles |
| 2 | No bundles captured (network failure, Cloudflare block, etc.) |
129 changes: 129 additions & 0 deletions tools/selector-health/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env node
import { chromium } from 'playwright';
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = join(__dirname, 'selectors.json');
const REPORT_PATH = join(__dirname, 'report.json');
const NAV_TIMEOUT_MS = 45000;
const IDLE_TIMEOUT_MS = 15000;

const config = JSON.parse(await readFile(CONFIG_PATH, 'utf8'));

const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
});
const page = await context.newPage();

const bundles = [];
page.on('response', async (response) => {
const url = response.url();
const contentType = response.headers()['content-type'] || '';
if (!/\.js(\?|$)/.test(url) && !contentType.includes('javascript')) return;
try {
const body = await response.text();
bundles.push({ url, size: body.length, body });
} catch {
// Response body no longer available; skip.
}
});

let navError = null;
try {
await page.goto(config.target, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
await page.waitForLoadState('networkidle', { timeout: IDLE_TIMEOUT_MS }).catch(() => {});
} catch (err) {
navError = err.message;
}

await browser.close();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for bundle reads before closing the browser

The script closes Chromium immediately after navigation, but bundle collection happens in an async page.on('response') handler and those response.text() reads are never awaited. On slower or larger JS responses, closing the context can make response.text() fail, and the catch block silently drops those bundles, which undercounts needles and can trigger false critical/high failures in CI. Track pending response-read promises and await them before closing the browser.

Useful? React with 👍 / 👎.


const haystack = bundles.map((b) => b.body).join('\n');
const haystackBytes = haystack.length;

const results = config.selectors.map((sel) => {
const min = typeof sel.minOccurrences === 'number' ? sel.minOccurrences : 1;
let occurrences = 0;
const matchedBundles = [];
for (const b of bundles) {
let from = 0;
let bundleHits = 0;
while (true) {
const idx = b.body.indexOf(sel.needle, from);
if (idx === -1) break;
bundleHits++;
from = idx + sel.needle.length;
}
if (bundleHits > 0) {
occurrences += bundleHits;
matchedBundles.push(b.url);
}
}
return {
id: sel.id,
testid: sel.testid,
needle: sel.needle,
criticality: sel.criticality,
scripts: sel.scripts,
minOccurrences: min,
occurrences,
found: occurrences >= min,
foundInBundles: matchedBundles,
};
});

const missing = results.filter((r) => !r.found);
const missingCriticalOrHigh = missing.filter(
(r) => r.criticality === 'critical' || r.criticality === 'high',
);

const report = {
ranAt: new Date().toISOString(),
target: config.target,
bundlesFetched: bundles.length,
bundleBytes: haystackBytes,
navError,
results,
summary: {
total: results.length,
found: results.length - missing.length,
missing: missing.length,
missingCriticalOrHigh: missingCriticalOrHigh.length,
},
};

await writeFile(REPORT_PATH, JSON.stringify(report, null, 2));

console.log(`Fetched ${bundles.length} JS bundles (${haystackBytes} bytes)`);
if (navError) console.log(`Navigation warning: ${navError}`);
for (const r of results) {
const icon = r.found ? 'OK ' : 'MISS';
console.log(
`${icon} [${r.criticality}] ${r.id} (${r.testid}) -- needle="${r.needle}" hits=${r.occurrences}/${r.minOccurrences}`,
);
}

if (bundles.length === 0) {
console.error('\nNo JS bundles captured. CI cannot verify selectors.');
process.exit(2);
}

if (missingCriticalOrHigh.length > 0) {
console.error(
`\n${missingCriticalOrHigh.length} critical/high selector(s) missing from x.com bundles:`,
);
for (const r of missingCriticalOrHigh) {
console.error(` - ${r.id} (${r.testid}) used by: ${r.scripts.join(', ') || '(none)'}`);
}
process.exit(1);
}

if (missing.length > 0) {
console.warn(`\n${missing.length} medium selector(s) missing (non-blocking).`);
}

process.exit(0);
62 changes: 62 additions & 0 deletions tools/selector-health/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions tools/selector-health/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "selector-health",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "CI health check that greps the live x.com JS bundles for data-testid selectors the userscripts depend on.",
"scripts": {
"check": "node check.mjs"
},
"dependencies": {
"playwright": "1.59.1"
},
"engines": {
"node": ">=20"
}
}
Loading