Skip to content
Merged
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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Runs the test suite and the linter on every push and pull request.
#
# `npm test` loads every module, checks it parses and imports, and exercises the
# duplicate-handling and navigation-index logic. `npm run lint` runs ESLint over
# the extension source.
#
# The comparator (`npm run test:compare`) is not here. It needs a reachable
# 4CAT, an API key and dataset keys, so it stays a local step.
#
# This runs on draft pull requests too. 4CAT opens its map_item sync pull
# requests as drafts, and those are the ones this is here to check.

name: Tests

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

permissions:
contents: read

# Pushing again to a branch cancels the run still going for the commit before it.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Tests and lint
runs-on: ubuntu-latest
# The job takes well under a minute. This caps a hung step.
timeout-minutes: 10
defaults:
run:
# The only package.json with dependencies and scripts lives here. The
# lint script steps up to the root itself.
working-directory: tests
steps:
- name: Check out Zeeschuimer
uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: tests/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

# Runs even when the tests above failed, so one run reports both.
- name: Run linter
if: '!cancelled()'
run: npm run lint
2 changes: 1 addition & 1 deletion create-zip-bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
VERSION=$(grep '"version"' manifest.json | cut -d'"' -f 4)
sed -i -E "s/\"version\": \"v[^\"]+\"/\"version\": \"v$VERSION\"/g" .zenodo.json
sed -i -E "s/v[0-9]+\.[0-9]+\.[0-9]+/v$VERSION/g" popup/interface.html
zip -r zeeschuimer-v$VERSION.zip . -x "*.DS_Store" "__MACOSX" js/mitm.js js/ponyfill-2.0.2.js js/streamsaver-2.0.3.js js/webtorrent.min.js -x "*.git*" -x "*.idea*" -x "create-zip.sh" -x "*.zip" -x "*.xpi" -x "tests*" -x "images/zeeschuimer-full.png" -x "images/chirico-full.png" -x "images/example_screenshot.png"
zip -r zeeschuimer-v$VERSION.zip . -x "*.DS_Store" "__MACOSX" js/mitm.js js/ponyfill-2.0.2.js js/streamsaver-2.0.3.js js/webtorrent.min.js -x "*.git*" -x "*.idea*" -x "create-zip.sh" -x "*.zip" -x "*.xpi" -x "tests*" -x "eslint.config.mjs" -x "images/zeeschuimer-full.png" -x "images/chirico-full.png" -x "images/example_screenshot.png"
2 changes: 1 addition & 1 deletion create-zip.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
VERSION=$(grep '"version"' manifest.json | cut -d'"' -f 4);
sed -I '' -E "s/\"version\": \"v[^\"]+\"/\"version\": \"v$VERSION\"/g" .zenodo.json
sed -I '' -E "s/v[0-9]+\.[0-9]+\.[0-9]+/v$VERSION/g" popup/interface.html
zip -r zeeschuimer-v$VERSION.zip . -x "*.DS_Store" "__MACOSX" js/mitm.js js/ponyfill-2.0.2.js js/streamsaver-2.0.3.js js/webtorrent.min.js -x "*.git*" -x "*.idea*" -x "*.sh" -x "*.zip" -x "*.xpi" -x "tests*" -x "images/zeeschuimer-full.png" -x "images/chirico-full.png" -x "images/example_screenshot.png"
zip -r zeeschuimer-v$VERSION.zip . -x "*.DS_Store" "__MACOSX" js/mitm.js js/ponyfill-2.0.2.js js/streamsaver-2.0.3.js js/webtorrent.min.js -x "*.git*" -x "*.idea*" -x "*.sh" -x "*.zip" -x "*.xpi" -x "tests*" -x "eslint.config.mjs" -x "images/zeeschuimer-full.png" -x "images/chirico-full.png" -x "images/example_screenshot.png"
89 changes: 89 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* ESLint configuration for Zeeschuimer.
*
* The rules are ESLint's recommended set, with the ones listed below switched
* off. The one doing most of the work is `no-undef`: manifest.json loads most
* of the extension as plain background scripts sharing one global scope, so the
* helpers in `js/lib.js` are free identifiers everywhere, and nothing else
* notices when one goes missing. That covers the `map_item` functions 4CAT
* generates and syncs in, where a helper called but never defined has been the
* most common way for a batch to arrive broken.
*
* The names those scripts share come from `tests/lib-globals.cjs`, which the
* Jest setup reads as well.
*
* Run it with `npm run lint` from `tests/`.
*/
import { createRequire } from 'node:module';

// This file sits at the repository root, and the dependencies live in `tests/`.
// Resolving from there finds both the `globals` package and the shared name list.
const require = createRequire(new URL('tests/package.json', import.meta.url));
const globals = require('globals');
const js = require('@eslint/js');
const { ALL_NAMES, BACKGROUND_SCRIPTS } = require('./lib-globals.cjs');

const zeeschuimer_globals = Object.fromEntries(
ALL_NAMES.map(name => [name, 'readonly']),
);

const rules = {
...js.configs.recommended.rules,

// Every empty block in the codebase is a `catch` that means it.
'no-empty': ['error', { allowEmptyCatch: true }],

// Off: the `map_item` bodies 4CAT generates trip these, and a sync replaces
// those blocks whole. None of them change what the code does.
'no-extra-boolean-cast': 'off', // !!value ? "yes" : "no"
'no-redeclare': 'off', // the same `var` declared twice in one function
'no-unused-vars': 'off', // variables assigned and then never read
'no-useless-assignment': 'off', // a value replaced before anything reads it
'no-useless-escape': 'off', // \[ and \/ inside a character class

// Off, but not cosmetic: `obj.hasOwnProperty(key)` throws if the JSON a
// platform sent has a key of that name. `Object.hasOwn(obj, key)` is the
// fix, at 31 places across js/ and modules/.
'no-prototype-builtins': 'off',
};

export default [
{
ignores: [
'inc/**', // third-party bundles, minified and not ours to fix
'.claude/**', // scratch worktrees hold copies of every module
// Not extension code: a Firefox profile's prefs.js, a stealth script
// written to run inside a page. What is, `npm test` runs.
'tests/**',
// `popup/interface.js` uses `init_tooltips` from `popup/tooltips.js`,
// a separate script `popup/interface.html` loads into the same scope,
// and nothing here works out that list. The other names no-undef
// reports there are inside `download_blob`, which the file marks unused.
'popup/**',
],
},
{
// Capture and map_item modules. `modules/package.json` marks these as
// ES modules; they still use the background-script globals.
files: ['modules/**/*.js'],
ignores: BACKGROUND_SCRIPTS,
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.browser, ...globals.webextensions, ...zeeschuimer_globals },
},
rules,
},
{
// Plain scripts: everything manifest.json lists under `background`, which
// is how `modules/_loader.js` lands here rather than above, plus the rest
// of `js/`.
files: ['js/**/*.js', ...BACKGROUND_SCRIPTS],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'script',
globals: { ...globals.browser, ...globals.webextensions, ...zeeschuimer_globals },
},
rules,
},
];
1 change: 0 additions & 1 deletion modules/gab.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ export function capture(response, source_platform_url, source_url) {
/// capture posts in search
if (source_url.indexOf('search?') >= 0 && data.statuses && Array.isArray(data.statuses)) {
for (let post of data.statuses) {
post["id"] = post.id;
post["c"] = removeHtmlTagsUsingDOMParser(post.content);
items.push(post);
}
Expand Down
16 changes: 0 additions & 16 deletions tests/Dockerfile.test

This file was deleted.

14 changes: 11 additions & 3 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
## Tests for Zeeschuimer

This folder contains testing code for Zeeschuimer. There are three suites,
each with a different purpose and a different runtime environment:
This folder contains testing code for Zeeschuimer. Each check below has a
different purpose and a different runtime environment:

| Suite | Tests | Environment | When it runs | Needs |
|----------------------------------|-----------------------------------------------------------|--------------------|---------------------------------|----------------------------------------|
| Selenium integration | Page captures real items from each supported platform | Real Firefox | Reviewer-supervised, manual | Firefox profile, sometimes a human |
| Duplicate-behavior unit (Jest) | DB merge / keep / update semantics in isolation | jsdom + fake-IDB | `npm test` (every push) | None |
| Module load smoke (Jest, Tier 1) | Each `modules/*.js` parses and imports cleanly | jsdom | `npm test` (every push) | None |
| Navigation index (Jest) | Tab and navigation bookkeeping in `js/zs-background.js` | jsdom + fake-IDB | `npm test` (every push) | None |
| ESLint | Every name `js/` and `modules/` use is defined somewhere | Node | `npm run lint` (every push) | None |
| `map_item` comparator (Jest, Tier 2) | JS `map_item` output matches 4CAT's Python mapping per item | jsdom + cross-fetch | `npm run test:compare` (on demand) | Live 4CAT, API key, dataset key(s) |

Hermetic suites (no external dependencies) live in `npm test`. Anything that
Expand Down Expand Up @@ -58,7 +60,7 @@ Tests are defined in `tests.json` with the following structure:
### Jest suites

**Prerequisites**
- Node.js (v18 or later) and npm
- Node.js (v20.19 or later, the floor ESLint sets) and npm
- `cd tests && npm install`

**Recommended: develop the tests inside Docker.** On Windows the global
Expand Down Expand Up @@ -151,6 +153,9 @@ npm test
# watch mode for the same
npm run test:watch

# ESLint over js/ and modules/
npm run lint

# the comparator — every dataset key in FOURCAT_DATASETS
npm run test:compare

Expand All @@ -171,6 +176,9 @@ npm run test:compare -- <dataset_key> --all
comparator. Add a dataset to `FOURCAT_DATASETS` that covers the case;
the comparator will pick it up.
- **End-to-end user flow in the extension.** Selenium.
- **A name that nothing defines** — a helper a `map_item` calls but no file
declares. Nothing to add: ESLint's `no-undef` covers every name in `js/` and
`modules/` already.

### Why the environments differ

Expand Down
146 changes: 146 additions & 0 deletions tests/lib-globals.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* The names Zeeschuimer's own scripts put into global scope.
*
* manifest.json loads the scripts under `background` as plain scripts, so what
* they declare at their top level is shared. Module code — and the `map_item`
* functions generated from 4CAT — uses those names without declaring or
* importing anything.
*
* Two things read that list:
* - `setup-globals.cjs`, which puts the helpers into scope for Jest.
* - `eslint.config.mjs`, which tells `no-undef` that these names exist.
*
* The names come out of the source, so adding a helper to `js/lib.js` needs no
* edit here.
*/

const fs = require('node:fs');
const path = require('node:path');
const espree = require('espree');

const ROOT = path.join(__dirname, '..');

function read(...parts) {
return fs.readFileSync(path.join(ROOT, ...parts), 'utf8');
}

// The scripts that share the background page's global scope.
const BACKGROUND_SCRIPTS = JSON.parse(read('manifest.json')).background?.scripts;

if (!Array.isArray(BACKGROUND_SCRIPTS) || BACKGROUND_SCRIPTS.length === 0) {
throw new Error(
'lib-globals.cjs: manifest.json has no background.scripts for this file to ' +
'read.'
);
}

// `inc/dexie.js` and `inc/he.js` are third-party bundles: minified, and wrapped
// so that nothing about them can be read off the source. Their names are written
// out here against the path the manifest loads them from.
const VENDORED_NAMES = {
'inc/dexie.js': ['Dexie'],
'inc/he.js': ['he'],
};

const unloaded = Object.keys(VENDORED_NAMES).filter(script => !BACKGROUND_SCRIPTS.includes(script));
if (unloaded.length > 0) {
throw new Error(
`lib-globals.cjs: ${unloaded.join(', ')} named above, but manifest.json does ` +
'not load it. A renamed file or a swapped-out library leaves an entry here ' +
'that no longer does anything; drop it, or correct the path.'
);
}

// Identify the names one statement declares. A statement that declares nothing like a
// function call, an assignment, or an if block gives back an empty list.
function declared_names(statement, script, source) {
if (statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') {
// functions and classes
return [statement.id.name];
}
if (statement.type === 'VariableDeclaration') {
// variables
return statement.declarations.map(declaration => {
if (declaration.id.type === 'Identifier') {
return declaration.id.name;
} else {
const line = declaration.loc.start.line;
throw new Error(
`lib-globals.cjs: ${script} line ${line} declares names in a ` +
'form this file does not read:\n\n' +
` ${source.split('\n')[line - 1].trim()}\n\n` +
'Add that form to declared_names(), or declare the names one ' +
'per line.'
);
}
});
}
return [];
}

// Identify the name one statement hangs off `window`, as js/zs-background.js
// does with `window.db = new Dexie(...)` and `window.zeeschuimer = {...}`.
function assigned_names(statement) {
// First drop anything that is not an assignment
if (statement.expression?.type !== 'AssignmentExpression') {
return [];
}

// Then take the name after the dot in `window.<name>`. Only that spelling:
// `window['db']` hides the name in a string and `window[key]` has no name in
// the file at all. Neither appears in js/zs-background.js.
const target = statement.expression.left;
if (!target.computed && target.object?.name === 'window') {
return [target.property.name];
}

return [];
}

// Every name a script puts into global scope. `body` holds the outermost
// statements only, so a helper written inside another one like `_traverse_data`
// inside `traverse_data` is not in the list.
function global_names(script, source) {
let parsed;
try {
parsed = espree.parse(source, { ecmaVersion: 'latest', sourceType: 'script', loc: true });
} catch (error) {
throw new Error(
`lib-globals.cjs: cannot read ${script}, which manifest.json loads as a ` +
`plain script:\n\n line ${error.lineNumber}: ${error.message}\n\n` +
'A background script cannot use import or export. If that is the problem ' +
'here, the browser will not load the file either.'
);
}

return parsed.body.flatMap(statement => [
...declared_names(statement, script, source),
...assigned_names(statement),
]);
}

// setup-globals.cjs evaluates js/lib.js and pulls these names back out of it.
const LIB_SOURCE = read('js/lib.js');
const LIB_NAMES = global_names('js/lib.js', LIB_SOURCE);

// Every script is expected to put something into global scope, so one that
// contributes nothing probably means this file could not read it rather than
// that there was nothing to find.
const ALL_NAMES = [...new Set(BACKGROUND_SCRIPTS.flatMap(script => {
if (script in VENDORED_NAMES) {
return VENDORED_NAMES[script];
}

const names = global_names(script, read(script));
if (names.length === 0) {
throw new Error(
`lib-globals.cjs: manifest.json loads ${script}, but no global names ` +
'could be read out of it. Add them to VENDORED_NAMES above, keyed by ' +
`'${script}' or, if it really does declare nothing, list it there ` +
'with an empty array.'
);
}
return names;
}))];

module.exports = { BACKGROUND_SCRIPTS, LIB_SOURCE, LIB_NAMES, ALL_NAMES };
Loading
Loading