Merge pull request 'Extension: fix credential-push 405, guard the publish path, add a unit suite' (#234) from dev into main
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 32s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 1m14s
Build images / sign-extension (push) Successful in 2m23s
Build images / build-ml (push) Successful in 3m53s
CI / integration (push) Successful in 3m56s
Build images / build-web (push) Successful in 2m34s
Build images / build-agent (push) Successful in 13m33s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 32s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 1m14s
Build images / sign-extension (push) Successful in 2m23s
Build images / build-ml (push) Successful in 3m53s
CI / integration (push) Successful in 3m56s
Build images / build-web (push) Successful in 2m34s
Build images / build-agent (push) Successful in 13m33s
This commit was merged in pull request #234.
This commit is contained in:
@@ -2,6 +2,7 @@ name: CI
|
||||
|
||||
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
|
||||
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
||||
# - extension-version: guards the extension publish path (see the job).
|
||||
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
|
||||
# - frontend-build: vitest unit + vite build.
|
||||
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
|
||||
@@ -41,6 +42,121 @@ jobs:
|
||||
# catching syntax errors before the image build.
|
||||
run: python -m compileall -q agent/fc_agent
|
||||
|
||||
# Guards the extension publish path, which has no self-correcting behavior.
|
||||
#
|
||||
# build.yml's sign-extension job keys its AMO-signing cache purely on the
|
||||
# version string in extension/package.json: if an `ext-<version>` Forgejo
|
||||
# release already carries an XPI, signing is SKIPPED and that old signed XPI
|
||||
# is what build-web bakes into `:latest`. Nothing in that path inspects
|
||||
# whether extension/ actually changed — so a forgotten version bump ships a
|
||||
# stale extension on a fully green build, silently. (AMO can't help: it 409s
|
||||
# on re-signing a version, which is exactly why the cache exists.)
|
||||
#
|
||||
# This job makes that case loud, on the dev push, instead of invisible at
|
||||
# merge-to-main. It is pure git + text work — no deps, no services.
|
||||
extension-version:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history: the check diffs against the push's `before` SHA (or
|
||||
# the PR base), which a depth-1 clone wouldn't contain.
|
||||
fetch-depth: 0
|
||||
- name: Extension version guard
|
||||
env:
|
||||
BEFORE: ${{ github.event.before }}
|
||||
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
# busybox sh on the act_runner — no bashisms (family rule).
|
||||
ver() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'; }
|
||||
PKG=$(ver extension/package.json)
|
||||
MAN=$(ver extension/manifest.json)
|
||||
test -n "$PKG" || { echo "ERROR: no version found in extension/package.json"; exit 1; }
|
||||
test -n "$MAN" || { echo "ERROR: no version found in extension/manifest.json"; exit 1; }
|
||||
|
||||
# (1) Unconditional: the two version strings must agree. `web-ext sign`
|
||||
# reads manifest.json (package.json sits in --ignore-files and isn't
|
||||
# even inside the XPI), so AMO signs MAN and Firefox installs MAN.
|
||||
# build.yml keys its cache, release tag, XPI filename — and therefore
|
||||
# the version /api/extension/manifest reports to the update prompt —
|
||||
# on PKG. Divergence either hard-fails at AMO or ships a mislabelled
|
||||
# XPI whose update prompt lies about what's installed.
|
||||
if [ "$MAN" != "$PKG" ]; then
|
||||
echo "ERROR: extension version mismatch."
|
||||
echo " extension/manifest.json = $MAN <- what AMO signs / Firefox installs"
|
||||
echo " extension/package.json = $PKG <- what CI caches, names, and reports"
|
||||
echo "Set both to the same value."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# (2) If the SHIPPED extension changed, the version must have moved.
|
||||
#
|
||||
# Compare against MAIN, not against the previous push. The publish
|
||||
# decision is made at merge-to-main against whatever ext-<version>
|
||||
# already exists, so "differs from main" is the question that matters.
|
||||
# Diffing against the previous dev push instead would demand a fresh
|
||||
# bump on every iteration — push, tweak the extension again, and CI
|
||||
# would insist on a second bump that buys nothing, inflating the
|
||||
# version for no reason. On a main push there is no "main to compare
|
||||
# to" yet, so fall back to that push's own before-SHA.
|
||||
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||
BASE="${BEFORE:-}"
|
||||
else
|
||||
BASE=$(git rev-parse --verify -q origin/main 2>/dev/null || git rev-parse --verify -q main 2>/dev/null || echo "")
|
||||
# PR base is the fallback when main isn't in the clone at all.
|
||||
[ -n "$BASE" ] || BASE="${PR_BASE:-}"
|
||||
fi
|
||||
case "$BASE" in
|
||||
''|0000000000000000000000000000000000000000)
|
||||
echo "No usable base ref (no main in clone / first push) — skipping the bump check."
|
||||
echo "OK: extension version $PKG"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
|
||||
echo "Base commit $BASE not in this clone — skipping the bump check."
|
||||
echo "OK: extension version $PKG"
|
||||
exit 0
|
||||
fi
|
||||
# Exclusions mirror --ignore-files in extension/package.json's web-ext
|
||||
# scripts: these files are not packaged into the XPI, so touching them
|
||||
# (e.g. Renovate bumping the web-ext devDep, or editing a spec)
|
||||
# changes nothing shipped and must not demand a version bump.
|
||||
# KEEP IN SYNC with --ignore-files — a file packaged into the XPI but
|
||||
# excluded here is exactly the silent-stale-ship this job exists to
|
||||
# prevent. test/version.spec.js pins the two lists' shared intent.
|
||||
CHANGED=$(git diff --name-only "$BASE" HEAD -- extension/ \
|
||||
':(exclude)extension/package.json' \
|
||||
':(exclude)extension/package-lock.json' \
|
||||
':(exclude)extension/README.md' \
|
||||
':(exclude)extension/.gitignore' \
|
||||
':(exclude)extension/vitest.config.js' \
|
||||
':(exclude)extension/test/**')
|
||||
if [ -z "$CHANGED" ]; then
|
||||
echo "No packaged extension files changed since $BASE — nothing to guard."
|
||||
echo "OK: extension version $PKG"
|
||||
exit 0
|
||||
fi
|
||||
echo "Packaged extension files changed since $BASE:"
|
||||
echo "$CHANGED" | sed 's/^/ /'
|
||||
PKG_OLD=$(git show "$BASE:extension/package.json" 2>/dev/null | grep -E '"version"' | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
if [ -z "$PKG_OLD" ]; then
|
||||
echo "Could not read the base version — skipping the bump check."
|
||||
echo "OK: extension version $PKG"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PKG_OLD" = "$PKG" ]; then
|
||||
echo "ERROR: packaged extension files changed but the version is still $PKG."
|
||||
echo "build.yml would find the existing ext-$PKG release, skip AMO signing,"
|
||||
echo "and bake the OLD signed XPI into :latest — a green build shipping stale code."
|
||||
echo "Bump the version in BOTH extension/package.json and extension/manifest.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: extension version $PKG_OLD -> $PKG"
|
||||
|
||||
backend-lint-and-test:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: extension
|
||||
# Lint-only workflow. The sign-and-publish dance moved into build.yml's
|
||||
# Lint + unit tests. The sign-and-publish dance moved into build.yml's
|
||||
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
|
||||
# because sign-extension runs as a build-web dependency in the SAME workflow,
|
||||
# eliminating the prior race between build.yml and a separate extension.yml.
|
||||
@@ -10,10 +10,15 @@ on:
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.forgejo/workflows/extension.yml'
|
||||
# test/version.spec.js asserts ci.yml's extension-version guard never
|
||||
# ignores a file web-ext actually packages, so a ci.yml-only edit can
|
||||
# break this suite and must trigger it.
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -23,7 +28,13 @@ jobs:
|
||||
image: node:24-bookworm-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install web-ext
|
||||
run: cd extension && npm install --no-save --no-audit --no-fund
|
||||
# Not --no-save: vitest and web-ext are both real devDependencies now,
|
||||
# and the suite needs vitest resolvable from node_modules.
|
||||
- name: Install dev dependencies
|
||||
run: cd extension && npm install --no-audit --no-fund
|
||||
- name: Lint
|
||||
run: cd extension && npm run lint
|
||||
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
|
||||
# package version-consistency checks. No browser, no network.
|
||||
- name: Unit tests
|
||||
run: cd extension && npm run test:unit
|
||||
|
||||
+21
-3
@@ -11,12 +11,22 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
- python 3.14
|
||||
- ruff (analyzer for `backend/`, `tests/`, `alembic/`)
|
||||
- node (frontend job: `npm install` + vitest + vite build)
|
||||
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Forgejo registry push)
|
||||
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Fabled-Git registry push)
|
||||
|
||||
## Secondary runtime image
|
||||
|
||||
node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only.
|
||||
|
||||
The extension lane is the one job that does NOT run on `ci-python:3.14`: it
|
||||
needs a current Node for `web-ext` and vitest and nothing Python at all. Kept
|
||||
on the upstream slim image rather than adding a Node toolchain to `ci-python`,
|
||||
per `docs/process.md`'s "add deps to the image when used by >1 project".
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
- `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs
|
||||
- `npm install --no-audit --no-fund` — in `frontend-build` job
|
||||
- `npm install --no-audit --no-fund` — in `extension.yml`'s `lint` job (web-ext + vitest)
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -26,12 +36,20 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
"add deps to image when used by >1 project" rule: FC alone is one Python
|
||||
project, so the deps live in `requirements.txt` and install per-job.
|
||||
Reconsider when a second Fabled-family Python backend lands.
|
||||
- Integration uses Forgejo Actions `services:` + socket-discovered bridge IPs
|
||||
- Integration uses Fabled-Git Actions `services:` + socket-discovered bridge IPs
|
||||
because `act_runner` (swarm-runner v0.6+) puts services on the default
|
||||
bridge with no embedded DNS. The pattern is documented in the rulebook's
|
||||
`forgejo.md` "CI philosophy" section and FC's `ci.yml` is the canonical
|
||||
`fabled-git.md` "CI philosophy" section and FC's `ci.yml` is the canonical
|
||||
example.
|
||||
- No `package-lock.json` is tracked yet (FC's `feedback_no_local_runs`
|
||||
memory bans `npm install` locally). Using `npm install` rather than
|
||||
`npm ci` until a lockfile lands.
|
||||
- No `imagemagick` / `pandoc` per-job installs needed.
|
||||
- `extension/`'s vitest specs load `lib/*.js` by evaluating the real file as a
|
||||
classic script (`test/helpers/loadLib.js`) rather than adding `module.exports`
|
||||
shims to production code — the libs ship as `background.scripts`, not ES
|
||||
modules, so the specs exercise exactly the bytes packaged into the XPI.
|
||||
- Extension test files are excluded from the XPI via `--ignore-files` in
|
||||
`extension/package.json`, and the same paths are excluded from `ci.yml`'s
|
||||
`extension-version` guard. Those two lists must agree — `test/version.spec.js`
|
||||
asserts the guard never ignores a file web-ext actually packages.
|
||||
|
||||
+14
-5
@@ -11,7 +11,10 @@ class FabledCuratorAPI {
|
||||
|
||||
async init() {
|
||||
const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
||||
this.baseUrl = cfg.apiUrl || null;
|
||||
// Normalize on READ, not just on save: configs stored before the options
|
||||
// page started normalizing are missing the `/api` suffix, and this heals
|
||||
// them without the operator having to reopen Settings.
|
||||
this.baseUrl = normalizeApiUrl(cfg.apiUrl) || null;
|
||||
this.apiKey = cfg.apiKey || null;
|
||||
return this.isConfigured();
|
||||
}
|
||||
@@ -50,6 +53,13 @@ class FabledCuratorAPI {
|
||||
} catch {
|
||||
message = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
// 404/405 from FC almost always means the request never reached the JSON
|
||||
// API — it fell through to the SPA catch-all, which serves HTML on GET
|
||||
// and rejects everything else. Say so, rather than making the operator
|
||||
// decode "Method Not Allowed" on an endpoint that plainly allows POST.
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
message += ` — ${url} isn't the FC API. Check the FC URL in settings.`;
|
||||
}
|
||||
const err = new Error(message);
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
@@ -96,11 +106,10 @@ class FabledCuratorAPI {
|
||||
return this.request('GET', '/extension/manifest');
|
||||
}
|
||||
|
||||
// The web/SPA root: baseUrl with the trailing slash + `/api` suffix stripped.
|
||||
// Where the Vue router (artist pages) and the served XPI live, NOT the JSON
|
||||
// API. Used by OPEN_ARTIST_PAGE + the self-update check.
|
||||
// The web/SPA root: where the Vue router (artist pages) and the served XPI
|
||||
// live, NOT the JSON API. Used by OPEN_ARTIST_PAGE + the self-update check.
|
||||
webRoot() {
|
||||
return (this.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||
return webRootFromApiUrl(this.baseUrl);
|
||||
}
|
||||
|
||||
// Connection test = the cheapest read with auth.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Canonical FC endpoint derivation, shared by the background client and the
|
||||
* options page so a URL entered either way behaves identically.
|
||||
*
|
||||
* FC serves two things on one origin: the JSON API under `/api`, and the Vue
|
||||
* SPA from the root. `api.js` builds requests as `${baseUrl}/credentials`, so
|
||||
* the stored base URL has to carry the `/api` suffix.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accept what an operator would naturally type — the instance root
|
||||
* (`http://curator.example.com`) or the API root (`.../api`) — and return the
|
||||
* API root either way.
|
||||
*
|
||||
* Worth normalizing rather than validating: a root-form URL doesn't fail
|
||||
* loudly, it lands on the SPA catch-all, which answers `GET /credentials` with
|
||||
* 200 HTML and rejects `POST /credentials` with 405. The operator sees a
|
||||
* working Test Connection and a broken export.
|
||||
*/
|
||||
function normalizeApiUrl(raw) {
|
||||
const trimmed = (raw || '').trim().replace(/\/+$/, '');
|
||||
if (!trimmed) return '';
|
||||
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SPA root — where the Vue router (artist pages) and the served XPI live,
|
||||
* NOT the JSON API. Accepts either input form, same as normalizeApiUrl.
|
||||
*/
|
||||
function webRootFromApiUrl(raw) {
|
||||
return normalizeApiUrl(raw).replace(/\/api$/i, '');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
|
||||
"background": {
|
||||
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/api.js", "background/background.js"]
|
||||
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/url.js", "lib/api.js", "background/background.js"]
|
||||
},
|
||||
|
||||
"options_ui": {
|
||||
|
||||
@@ -21,9 +21,12 @@
|
||||
<body>
|
||||
<h1>FabledCurator extension</h1>
|
||||
|
||||
<label for="api-url">FC base URL</label>
|
||||
<input id="api-url" type="url" placeholder="http://curator.example.com/api" />
|
||||
<div class="hint">Find this on FC → Settings → Maintenance → Browser extension.</div>
|
||||
<label for="api-url">FC instance URL</label>
|
||||
<input id="api-url" type="url" placeholder="http://curator.example.com" />
|
||||
<div class="hint">
|
||||
Your FabledCurator address — with or without the trailing <code>/api</code>; both work.
|
||||
Find it on FC → Settings → Maintenance → Browser extension.
|
||||
</div>
|
||||
|
||||
<label for="api-key">Extension API key</label>
|
||||
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
|
||||
@@ -36,6 +39,7 @@
|
||||
|
||||
<div id="status" class="status" style="display:none;"></div>
|
||||
|
||||
<script src="../lib/url.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
|
||||
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
|
||||
const apiKey = document.getElementById('api-key').value.trim();
|
||||
if (!apiUrl || !apiKey) {
|
||||
showStatus('Both fields are required.', 'err');
|
||||
@@ -16,11 +16,14 @@ async function save() {
|
||||
}
|
||||
await browser.storage.local.set({ apiUrl, apiKey });
|
||||
await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']);
|
||||
showStatus('Saved.', 'ok');
|
||||
// Show what was actually stored — the operator may have typed the instance
|
||||
// root and it was normalized to the API root.
|
||||
document.getElementById('api-url').value = apiUrl;
|
||||
showStatus(`Saved — using ${apiUrl}`, 'ok');
|
||||
}
|
||||
|
||||
async function test() {
|
||||
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
|
||||
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
|
||||
const apiKey = document.getElementById('api-key').value.trim();
|
||||
if (!apiUrl || !apiKey) {
|
||||
showStatus('Fill both fields first.', 'err');
|
||||
@@ -31,8 +34,23 @@ async function test() {
|
||||
method: 'GET',
|
||||
headers: { 'X-Extension-Key': apiKey },
|
||||
});
|
||||
if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok');
|
||||
else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
|
||||
if (!r.ok) {
|
||||
showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
|
||||
return;
|
||||
}
|
||||
// A 200 is NOT sufficient. If the URL resolves to the Vue SPA instead of
|
||||
// the JSON API, the catch-all route returns 200 with an HTML document —
|
||||
// which used to report "Connected" on a config that could not POST at all.
|
||||
const contentType = r.headers.get('content-type') || '';
|
||||
if (!contentType.includes('json')) {
|
||||
showStatus(
|
||||
`${apiUrl} answered with ${contentType || 'no content-type'}, not JSON `
|
||||
+ '— that looks like the FC web UI rather than its API.',
|
||||
'err',
|
||||
);
|
||||
return;
|
||||
}
|
||||
showStatus(`Connected to ${apiUrl} — HTTP ${r.status}.`, 'ok');
|
||||
} catch (e) {
|
||||
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
"lint": "web-ext lint --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore",
|
||||
"start": "web-ext run --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --firefox=firefox",
|
||||
"build": "web-ext build --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --overwrite-dest",
|
||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||
"lint": "web-ext lint --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\"",
|
||||
"start": "web-ext run --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --firefox=firefox",
|
||||
"build": "web-ext build --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --overwrite-dest",
|
||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.0",
|
||||
"web-ext": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const LIB_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'lib')
|
||||
|
||||
/**
|
||||
* Load an extension lib and hand back the globals it declares.
|
||||
*
|
||||
* The files under lib/ are CLASSIC scripts, not ES modules: manifest.json
|
||||
* lists them in `background.scripts` and options.html pulls them in with a
|
||||
* plain <script> tag, so they declare bare functions into a shared scope and
|
||||
* export nothing. Rather than bolt a `module.exports` shim onto production
|
||||
* code that would never run in the browser, evaluate the real file the same
|
||||
* way the browser does — as a script body — and pick the declarations back out.
|
||||
*
|
||||
* This means the specs exercise the exact bytes that get packaged into the
|
||||
* XPI. Only usable for libs that touch no browser APIs at load time
|
||||
* (url.js, platforms.js); cookies.js and api.js reference `browser.*` and
|
||||
* would need stubbing, which is why they aren't loaded this way.
|
||||
*
|
||||
* @param {string} filename e.g. 'url.js'
|
||||
* @param {string[]} names declarations to return, e.g. ['normalizeApiUrl']
|
||||
*/
|
||||
export function loadLib(filename, names) {
|
||||
const source = readFileSync(path.join(LIB_DIR, filename), 'utf8')
|
||||
const factory = new Function(`${source}\nreturn { ${names.join(', ')} }`)
|
||||
return factory()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { loadLib } from './helpers/loadLib.js'
|
||||
|
||||
const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib(
|
||||
'platforms.js',
|
||||
['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS']
|
||||
)
|
||||
|
||||
describe('getPlatformFromUrl', () => {
|
||||
it('identifies each platform from a domain URL', () => {
|
||||
expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon')
|
||||
expect(getPlatformFromUrl('https://subscribestar.adult/someone')).toBe('subscribestar')
|
||||
expect(getPlatformFromUrl('https://www.hentai-foundry.com/user/someone')).toBe('hentaifoundry')
|
||||
expect(getPlatformFromUrl('https://discord.com/channels/@me')).toBe('discord')
|
||||
expect(getPlatformFromUrl('https://www.pixiv.net/en/users/123')).toBe('pixiv')
|
||||
expect(getPlatformFromUrl('https://www.deviantart.com/someone')).toBe('deviantart')
|
||||
})
|
||||
|
||||
it('accepts http as well as https, with or without www', () => {
|
||||
expect(getPlatformFromUrl('http://patreon.com/Atole')).toBe('patreon')
|
||||
expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon')
|
||||
})
|
||||
|
||||
it('returns null for unrelated hosts', () => {
|
||||
expect(getPlatformFromUrl('https://example.com/patreon.com')).toBe(null)
|
||||
expect(getPlatformFromUrl('https://not-patreon.com/Atole')).toBe(null)
|
||||
expect(getPlatformFromUrl('')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isArtistPage', () => {
|
||||
// Regression cases from issue #1485: the Add-to-FC button vanished once the
|
||||
// operator SUBSCRIBED to a creator, because Patreon serves subscribed users
|
||||
// the /cw/ ("creator workspace") URL and the pattern only matched the bare
|
||||
// root. All three creator URL shapes must match, plus inner pages — the
|
||||
// button matters most exactly when you're subscribed.
|
||||
it('matches all three Patreon creator URL shapes', () => {
|
||||
expect(isArtistPage('https://www.patreon.com/Atole', 'patreon')).toBe(true)
|
||||
expect(isArtistPage('https://www.patreon.com/c/Atole', 'patreon')).toBe(true)
|
||||
expect(isArtistPage('https://www.patreon.com/cw/Atole', 'patreon')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches Patreon creator inner pages', () => {
|
||||
expect(isArtistPage('https://www.patreon.com/cw/Atole/posts', 'patreon')).toBe(true)
|
||||
expect(isArtistPage('https://www.patreon.com/Atole/membership', 'patreon')).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes Patreon navigation pages that are not creators', () => {
|
||||
for (const nav of ['home', 'search', 'messages', 'notifications', 'library', 'settings']) {
|
||||
expect(isArtistPage(`https://www.patreon.com/${nav}`, 'patreon')).toBe(false)
|
||||
expect(isArtistPage(`https://www.patreon.com/${nav}/anything`, 'patreon')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches SubscribeStar creator roots on both TLDs but not feed pages', () => {
|
||||
expect(isArtistPage('https://subscribestar.adult/someone', 'subscribestar')).toBe(true)
|
||||
expect(isArtistPage('https://subscribestar.com/someone', 'subscribestar')).toBe(true)
|
||||
expect(isArtistPage('https://subscribestar.adult/feed', 'subscribestar')).toBe(false)
|
||||
expect(isArtistPage('https://subscribestar.adult/messages', 'subscribestar')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches Hentai Foundry user pages only', () => {
|
||||
expect(isArtistPage('https://www.hentai-foundry.com/user/someone', 'hentaifoundry')).toBe(true)
|
||||
expect(isArtistPage('https://www.hentai-foundry.com/pictures/popular', 'hentaifoundry')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('matches Pixiv numeric user pages, with or without the /en/ prefix', () => {
|
||||
expect(isArtistPage('https://www.pixiv.net/users/12345', 'pixiv')).toBe(true)
|
||||
expect(isArtistPage('https://www.pixiv.net/en/users/12345', 'pixiv')).toBe(true)
|
||||
expect(isArtistPage('https://www.pixiv.net/en/artworks/999', 'pixiv')).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes DeviantArt navigation roots', () => {
|
||||
expect(isArtistPage('https://www.deviantart.com/someone', 'deviantart')).toBe(true)
|
||||
expect(isArtistPage('https://www.deviantart.com/home', 'deviantart')).toBe(false)
|
||||
expect(isArtistPage('https://www.deviantart.com/watch', 'deviantart')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a platform with no artist pattern (discord)', () => {
|
||||
expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for an unknown platform key', () => {
|
||||
expect(isArtistPage('https://www.patreon.com/Atole', 'nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('platform table integrity', () => {
|
||||
it('gives every artist pattern a corresponding platform entry', () => {
|
||||
// A pattern keyed to a platform that no longer exists is dead code that
|
||||
// silently never fires; the reverse (a platform with no pattern) is the
|
||||
// legitimate discord case, so only this direction is an error.
|
||||
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
|
||||
expect(Object.keys(PLATFORMS)).toContain(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every platform the fields the popup renders', () => {
|
||||
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||
expect(platform.name, `${key}.name`).toBeTruthy()
|
||||
expect(platform.color, `${key}.color`).toMatch(/^#[0-9A-Fa-f]{6}$/)
|
||||
expect(['cookies', 'token'], `${key}.authType`).toContain(platform.authType)
|
||||
expect(platform.urlPattern, `${key}.urlPattern`).toBeInstanceOf(RegExp)
|
||||
expect(Array.isArray(platform.domains), `${key}.domains`).toBe(true)
|
||||
expect(platform.domains.length, `${key}.domains`).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every artist URL matched by its own platform pattern too', () => {
|
||||
// isArtistPage is only ever consulted after getPlatformFromUrl resolves a
|
||||
// key, so an artist pattern matching a URL its platform's urlPattern
|
||||
// rejects would be unreachable.
|
||||
const samples = {
|
||||
patreon: 'https://www.patreon.com/cw/Atole',
|
||||
subscribestar: 'https://subscribestar.adult/someone',
|
||||
hentaifoundry: 'https://www.hentai-foundry.com/user/someone',
|
||||
deviantart: 'https://www.deviantart.com/someone',
|
||||
pixiv: 'https://www.pixiv.net/en/users/12345'
|
||||
}
|
||||
for (const [key, url] of Object.entries(samples)) {
|
||||
expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true)
|
||||
expect(getPlatformFromUrl(url), `${key} urlPattern`).toBe(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { loadLib } from './helpers/loadLib.js'
|
||||
|
||||
const { normalizeApiUrl, webRootFromApiUrl } = loadLib('url.js', [
|
||||
'normalizeApiUrl',
|
||||
'webRootFromApiUrl'
|
||||
])
|
||||
|
||||
describe('normalizeApiUrl', () => {
|
||||
// The bug this exists for (issue #2393): the instance root was accepted and
|
||||
// stored verbatim, so every request went to /credentials instead of
|
||||
// /api/credentials. That path is a Vue router route, so the SPA catch-all
|
||||
// answered GET with 200 HTML and rejected POST with 405 — which read as a
|
||||
// backend bug rather than a URL one.
|
||||
it('appends /api to an instance root', () => {
|
||||
expect(normalizeApiUrl('http://curator.traefik.internal')).toBe(
|
||||
'http://curator.traefik.internal/api'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an API root alone rather than doubling the suffix', () => {
|
||||
expect(normalizeApiUrl('http://curator.traefik.internal/api')).toBe(
|
||||
'http://curator.traefik.internal/api'
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent', () => {
|
||||
const once = normalizeApiUrl('http://curator.example.com')
|
||||
expect(normalizeApiUrl(once)).toBe(once)
|
||||
})
|
||||
|
||||
it('strips trailing slashes before deciding', () => {
|
||||
expect(normalizeApiUrl('http://curator.example.com/')).toBe('http://curator.example.com/api')
|
||||
expect(normalizeApiUrl('http://curator.example.com///')).toBe('http://curator.example.com/api')
|
||||
expect(normalizeApiUrl('http://curator.example.com/api/')).toBe('http://curator.example.com/api')
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace (paste artifacts)', () => {
|
||||
expect(normalizeApiUrl(' http://curator.example.com ')).toBe(
|
||||
'http://curator.example.com/api'
|
||||
)
|
||||
})
|
||||
|
||||
it('matches the /api suffix case-insensitively', () => {
|
||||
expect(normalizeApiUrl('http://curator.example.com/API')).toBe('http://curator.example.com/API')
|
||||
})
|
||||
|
||||
it('returns empty string for empty/nullish input, never a bare "/api"', () => {
|
||||
// isConfigured() gates on truthiness, so a bogus '/api' here would read as
|
||||
// "configured" and produce a request against the options page's own origin.
|
||||
expect(normalizeApiUrl('')).toBe('')
|
||||
expect(normalizeApiUrl(' ')).toBe('')
|
||||
expect(normalizeApiUrl(null)).toBe('')
|
||||
expect(normalizeApiUrl(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('does not treat a path merely containing "api" as the suffix', () => {
|
||||
expect(normalizeApiUrl('http://curator.example.com/apiary')).toBe(
|
||||
'http://curator.example.com/apiary/api'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a subpath deployment', () => {
|
||||
expect(normalizeApiUrl('http://host.internal/curator')).toBe('http://host.internal/curator/api')
|
||||
})
|
||||
})
|
||||
|
||||
describe('webRootFromApiUrl', () => {
|
||||
// The SPA root, where the Vue router and the served XPI live. Used by
|
||||
// OPEN_ARTIST_PAGE and the self-update check — NOT the JSON API.
|
||||
it('strips the /api suffix', () => {
|
||||
expect(webRootFromApiUrl('http://curator.example.com/api')).toBe('http://curator.example.com')
|
||||
})
|
||||
|
||||
it('accepts an instance root unchanged', () => {
|
||||
expect(webRootFromApiUrl('http://curator.example.com')).toBe('http://curator.example.com')
|
||||
})
|
||||
|
||||
it('agrees with normalizeApiUrl in both directions', () => {
|
||||
for (const input of ['http://curator.example.com', 'http://curator.example.com/api']) {
|
||||
expect(normalizeApiUrl(webRootFromApiUrl(input))).toBe(normalizeApiUrl(input))
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a subpath deployment', () => {
|
||||
expect(webRootFromApiUrl('http://host.internal/curator/api')).toBe('http://host.internal/curator')
|
||||
})
|
||||
|
||||
it('returns empty string for empty/nullish input', () => {
|
||||
expect(webRootFromApiUrl('')).toBe('')
|
||||
expect(webRootFromApiUrl(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8'))
|
||||
|
||||
describe('extension version consistency', () => {
|
||||
// Duplicates check (1) of ci.yml's extension-version job, deliberately.
|
||||
// That job is the gate that can't be bypassed; this spec is the one that
|
||||
// fails in a second on the developer's own CI lane with a readable diff.
|
||||
// The two version strings feed different systems and nothing else reconciles
|
||||
// them:
|
||||
// manifest.json -> what `web-ext sign` signs, so what Firefox installs
|
||||
// (package.json is in --ignore-files, not in the XPI)
|
||||
// package.json -> build.yml's AMO cache key, the ext-<version> release
|
||||
// tag, the XPI filename, and therefore the version
|
||||
// /api/extension/manifest reports to the update prompt
|
||||
it('keeps manifest.json and package.json in lockstep', () => {
|
||||
const manifest = read('manifest.json')
|
||||
const pkg = read('package.json')
|
||||
expect(manifest.version).toBe(pkg.version)
|
||||
})
|
||||
|
||||
it('uses a plain dotted numeric version AMO will accept', () => {
|
||||
// AMO rejects exotic version strings, and build.yml embeds this value in a
|
||||
// release tag and a filename — so anything needing escaping breaks the
|
||||
// publish path rather than the extension.
|
||||
expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/)
|
||||
})
|
||||
|
||||
it('declares manifest v3', () => {
|
||||
expect(read('manifest.json').manifest_version).toBe(3)
|
||||
})
|
||||
|
||||
it('never lets the CI guard ignore a file that actually ships', () => {
|
||||
// ci.yml's extension-version job skips its bump check for paths it deems
|
||||
// non-shipping. If it excludes something web-ext DOES package, a real
|
||||
// change to shipped code passes the guard unnoticed — precisely the
|
||||
// silent-stale-ship the guard exists to stop. The reverse drift (guard
|
||||
// stricter than web-ext) only costs a needless bump, so it isn't asserted.
|
||||
const ci = readFileSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows', 'ci.yml'), 'utf8')
|
||||
const lint = read('package.json').scripts.lint
|
||||
const after = lint.split('--ignore-files')[1] ?? ''
|
||||
const ignored = new Set(
|
||||
after
|
||||
.split(/\s+/)
|
||||
.filter((tok) => tok && !tok.startsWith('--'))
|
||||
.map((tok) => tok.replace(/^["']|["']$/g, ''))
|
||||
)
|
||||
expect(ignored.size, 'parsed --ignore-files from the lint script').toBeGreaterThan(0)
|
||||
|
||||
const guarded = [...ci.matchAll(/:\(exclude\)extension\/(\S+?)'/g)].map((m) => m[1])
|
||||
expect(guarded.length, 'parsed :(exclude) entries from ci.yml').toBeGreaterThan(0)
|
||||
for (const entry of guarded) {
|
||||
expect(ignored, `ci.yml excludes "${entry}" but web-ext packages it`).toContain(entry)
|
||||
}
|
||||
})
|
||||
|
||||
it('lists every background script that exists, in dependency order', () => {
|
||||
// url.js must load BEFORE api.js: api.js calls normalizeApiUrl at
|
||||
// init()-time, and these are classic scripts sharing one scope, so a
|
||||
// reordering here is a runtime ReferenceError with no build-time signal.
|
||||
const scripts = read('manifest.json').background.scripts
|
||||
for (const rel of scripts) {
|
||||
expect(() => readFileSync(path.join(EXT_DIR, rel)), `missing ${rel}`).not.toThrow()
|
||||
}
|
||||
expect(scripts.indexOf('lib/url.js')).toBeLessThan(scripts.indexOf('lib/api.js'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Mirrors frontend/vitest.config.js, minus the Vue plugin — the extension has
|
||||
// no SFCs and mounts nothing. Pure-logic specs only, so `node` is enough; the
|
||||
// libs under test are deliberately the ones with no browser-API surface (see
|
||||
// test/helpers/loadLib.js).
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.spec.js'],
|
||||
passWithNoTests: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user