From e16b23a5dce62793df00a67373f0117d5f3d66d4 Mon Sep 17 00:00:00 2001 From: digitalby Date: Mon, 20 Apr 2026 02:11:35 +0200 Subject: [PATCH] ci: add weekly selector health canary for x.com bundle identifiers --- .github/workflows/selector-health.yml | 95 +++++++++++++++++ .gitignore | 2 + CLAUDE.md | 2 + README.md | 2 + tools/selector-health/README.md | 36 +++++++ tools/selector-health/check.mjs | 129 ++++++++++++++++++++++++ tools/selector-health/package-lock.json | 62 ++++++++++++ tools/selector-health/package.json | 16 +++ tools/selector-health/selectors.json | 98 ++++++++++++++++++ 9 files changed, 442 insertions(+) create mode 100644 .github/workflows/selector-health.yml create mode 100644 .gitignore create mode 100644 tools/selector-health/README.md create mode 100644 tools/selector-health/check.mjs create mode 100644 tools/selector-health/package-lock.json create mode 100644 tools/selector-health/package.json create mode 100644 tools/selector-health/selectors.json diff --git a/.github/workflows/selector-health.yml b/.github/workflows/selector-health.yml new file mode 100644 index 0000000..48711b2 --- /dev/null +++ b/.github/workflows/selector-health.yml @@ -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 "
Full report" + echo + echo '```json' + cat tools/selector-health/report.json + echo '```' + echo + echo "
" + } > "$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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..526bbf8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +tools/selector-health/report.json diff --git a/CLAUDE.md b/CLAUDE.md index 2e97602..e687cc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-"]` diff --git a/README.md b/README.md index d281cec..6c47d45 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/tools/selector-health/README.md b/tools/selector-health/README.md new file mode 100644 index 0000000..1af6602 --- /dev/null +++ b/tools/selector-health/README.md @@ -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.) | diff --git a/tools/selector-health/check.mjs b/tools/selector-health/check.mjs new file mode 100644 index 0000000..b0d57f5 --- /dev/null +++ b/tools/selector-health/check.mjs @@ -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(); + +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); diff --git a/tools/selector-health/package-lock.json b/tools/selector-health/package-lock.json new file mode 100644 index 0000000..8f039fe --- /dev/null +++ b/tools/selector-health/package-lock.json @@ -0,0 +1,62 @@ +{ + "name": "selector-health", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "selector-health", + "version": "1.0.0", + "dependencies": { + "playwright": "1.59.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tools/selector-health/package.json b/tools/selector-health/package.json new file mode 100644 index 0000000..fbaf769 --- /dev/null +++ b/tools/selector-health/package.json @@ -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" + } +} diff --git a/tools/selector-health/selectors.json b/tools/selector-health/selectors.json new file mode 100644 index 0000000..162ae6b --- /dev/null +++ b/tools/selector-health/selectors.json @@ -0,0 +1,98 @@ +{ + "source": "Mirrors the 'Twitter/X DOM Patterns' section of CLAUDE.md. Each entry's 'needle' is a proxy identifier present in the compiled x.com React bundle whose absence would indicate the feature we depend on has been renamed or removed. See README.md for what this check does and does not detect.", + "target": "https://x.com/jack/status/20", + "selectors": [ + { + "id": "tweet-article", + "testid": "tweet", + "needle": "[data-testid=\"tweet\"]", + "minOccurrences": 1, + "criticality": "critical", + "why": "Twitter's own code uses `document.querySelector('[data-testid=\"tweet\"]')`, so this literal appears in the bundle. If it disappears, they've renamed the wrapper.", + "scripts": [ + "twitter-delete-hotkey.user.js", + "twitter-post-activity-hotkeys.user.js", + "twitter-quote-hotkey.user.js", + "twitter-profile-hotkey.user.js", + "twitter-custom-keys.lib.js" + ] + }, + { + "id": "three-dot-menu", + "testid": "caret", + "needle": "TWEET_CARET", + "minOccurrences": 1, + "criticality": "critical", + "why": "Analytics scope constant for the tweet three-dot menu. Narrow and stable.", + "scripts": [ + "twitter-delete-hotkey.user.js", + "twitter-post-activity-hotkeys.user.js", + "twitter-usercell-hotkeys.user.js", + "twitter-quote-hotkey.user.js" + ] + }, + { + "id": "app-bar-back", + "testid": "app-bar-back", + "needle": "appBarBack", + "minOccurrences": 3, + "criticality": "high", + "why": "Camel-case feature identifier for the SPA header back button.", + "scripts": ["twitter-backspace-back.user.js"] + }, + { + "id": "user-name", + "testid": "User-Name", + "needle": "UserName", + "minOccurrences": 10, + "criticality": "high", + "why": "Component name for the user name container in a tweet.", + "scripts": ["twitter-profile-hotkey.user.js", "twitter-inline-follower-count.user.js"] + }, + { + "id": "user-avatar-container-prefix", + "testid": "UserAvatar-Container-", + "needle": "UserAvatar-Container-", + "minOccurrences": 1, + "criticality": "high", + "why": "The literal prefix string appears in bundle source when Twitter constructs the testid.", + "scripts": ["twitter-profile-hotkey.user.js", "twitter-inline-follower-count.user.js"] + }, + { + "id": "quote-tweet", + "testid": "quoteTweet", + "needle": "QuoteTweet", + "minOccurrences": 10, + "criticality": "high", + "why": "Component family name for embedded quote tweets.", + "scripts": ["twitter-quote-hotkey.user.js"] + }, + { + "id": "user-cell", + "testid": "UserCell", + "needle": "UserCell", + "minOccurrences": 5, + "criticality": "high", + "why": "Component name for user-list row cells.", + "scripts": ["twitter-usercell-hotkeys.user.js", "twitter-inline-follower-count.user.js"] + }, + { + "id": "retweet", + "testid": "retweet", + "needle": "retweet", + "minOccurrences": 10, + "criticality": "medium", + "why": "Feature verb; fires if X removes the retweet concept entirely.", + "scripts": [] + }, + { + "id": "unretweet", + "testid": "unretweet", + "needle": "unretweet", + "minOccurrences": 3, + "criticality": "medium", + "why": "Undo action for retweet.", + "scripts": [] + } + ] +}