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
42 changes: 42 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: test

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
name: ${{ matrix.os }}, Node ${{ matrix.node }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [22]
include:
- os: ubuntu-latest
node: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install ffmpeg (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update -q && sudo apt-get install -y -q ffmpeg
- name: Install ffmpeg (macOS)
if: runner.os == 'macOS'
run: brew install ffmpeg
- name: Install ffmpeg (Windows)
if: runner.os == 'Windows'
run: choco install ffmpeg -y --no-progress
- run: npm ci
- name: Check the setup
run: node bin/explainroo.js doctor
continue-on-error: true
- run: npm test
11 changes: 10 additions & 1 deletion src/math.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Typesets locally in Node. The page gets paths, not fonts or SVG references.
import fs from 'node:fs';
import crypto from 'node:crypto';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { absolutize, parsePath, serialize } from '../engine/pathdata.js';

export const MAX_TEX_LENGTH = 4096;
Expand All @@ -15,14 +17,21 @@ export async function typesetMath(tex, display = true, cacheFile = null) {
if (typeof display !== 'boolean') throw new Error('math: display must be true or false');
if (cacheFile && fs.existsSync(cacheFile)) return JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
ready ??= import('mathjax').then(({ default: mj }) => mj.init({
loader: { load: ['input/tex', 'output/svg'] },
// MathJax imports its parts by file path. On Windows a bare path like
// D:\\... is not a valid import specifier, so hand it a file URL.
loader: {
load: ['input/tex', 'output/svg'],
require: (file) => import(path.isAbsolute(file) ? pathToFileURL(file).href : file),
failed: (error) => { throw error; },
},
tex: {
packages: { '[-]': ['autoload', 'require', 'noundefined'] },
formatError: (_jax, error) => { throw new Error(error.message); },
},
svg: { fontCache: 'none' },
}));
const mj = await ready;
if (!mj?.startup) throw new Error('math: MathJax could not start. Run npm install in the explainroo folder.');
// Each formula has its own macros and equation counters, whatever the order.
const doc = mj.startup.mathjax.document('', { InputJax: mj.startup.getInputJax(), OutputJax: mj.startup.getOutputJax() });
const node = await doc.convertPromise(tex, { display });
Expand Down
2 changes: 2 additions & 0 deletions test/export-browser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ test('exported player runs at a sub-path with matching frames, clips and accessi
await page.locator('#player').focus();
await page.keyboard.press('f');
await page.waitForFunction(() => !!document.fullscreenElement);
// The label follows the fullscreenchange event, a moment after the element changes.
await page.waitForFunction(() => document.querySelector('#fullscreen').getAttribute('aria-label') === 'Exit fullscreen');
assert.equal(await page.getByRole('button', { name: 'Exit fullscreen' }).count(), 1);
assert.equal(await page.locator('#fullscreen').getAttribute('title'), 'Exit fullscreen');
assert.equal(await page.locator('#fullscreen').getAttribute('data-icon'), 'minimize');
Expand Down
2 changes: 1 addition & 1 deletion test/export.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function fixture(t) {
return { dir, project, timeline: buildTimeline(project, {}) };
}

function files(dir) { return fs.readdirSync(dir, { recursive: true }).filter(f => fs.statSync(path.join(dir, f)).isFile()).sort(); }
function files(dir) { return fs.readdirSync(dir, { recursive: true }).filter(f => fs.statSync(path.join(dir, f)).isFile()).map(f => f.split(path.sep).join('/')).sort(); }

test('player copies only used assets, font families and local modules, with public state', t => {
const { dir, project, timeline } = fixture(t);
Expand Down
6 changes: 3 additions & 3 deletions test/multi-size-browser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ function fixture(t) {
}

function run(command, dir, flags = []) {
const r = spawnSync(process.execPath, [bin, command, dir, ...flags, '--json'], { encoding: 'utf8', timeout: 30000 });
const r = spawnSync(process.execPath, [bin, command, dir, ...flags, '--json'], { encoding: 'utf8', timeout: 90000 });
assert.equal(r.status, 0, `${command}: ${r.stderr}\n${r.stdout}`);
return JSON.parse(r.stdout);
}

test('CLI renders use size names for every extra file and --out wins with and without --draft', { skip: !chrome, timeout: 120000 }, async t => {
test('CLI renders use size names for every extra file and --out wins with and without --draft', { skip: !chrome, timeout: 300000 }, async t => {
const dir = fixture(t);
const voiceFile = voicePaths(loadProject(dir), 'intro').json;
const mtime = fs.statSync(voiceFile).mtimeMs;
Expand Down Expand Up @@ -77,7 +77,7 @@ test('CLI renders use size names for every extra file and --out wins with and wi
assert.ok(fs.existsSync(path.join(dir, 'out/video-tiktok.mp4')));
});

test('CLI still, sheet and check use the override, while existing names stay unchanged', { skip: !chrome, timeout: 60000 }, t => {
test('CLI still, sheet and check use the override, while existing names stay unchanged', { skip: !chrome, timeout: 180000 }, t => {
const dir = fixture(t);
for (const [flags, suffix] of [[[], ''], [['--size', 'tiktok'], '-tiktok']]) {
const still = run('still', dir, [...flags, '--scale', '0.1']).stills[0];
Expand Down
6 changes: 3 additions & 3 deletions test/multi-size.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ test('output paths keep existing names or add the explicit size to every output
['stills', ''], ['sheet', '.jpg'], ['sheet-intro', '.jpg'],
];
for (const [name, ext] of names) {
assert.equal(outputPath(project, name, ext), `/tmp/video/out/${name}${ext}`);
assert.equal(outputPath(project, name, ext), path.resolve(`/tmp/video/out/${name}${ext}`));
for (const size of ['tiktok', 'youtube', '9:16', '1:1', '1080x1350']) {
assert.equal(outputPath({ ...project, sizeOverride: size }, name, ext), `/tmp/video/out/${name}-${size.replace(':', '-')}${ext}`);
assert.equal(outputPath({ ...project, sizeOverride: size }, name, ext), path.resolve(`/tmp/video/out/${name}-${size.replace(':', '-')}${ext}`));
}
}
for (const sizeOverride of [undefined, 'tiktok']) {
for (const name of ['video', 'draft']) {
assert.equal(outputPath({ ...project, sizeOverride }, name, '.mp4', 'chosen.mp4'), path.resolve('chosen.mp4'));
assert.equal(outputPath({ ...project, sizeOverride }, name, '.gif', '/tmp/chosen.gif'), '/tmp/chosen.gif');
assert.equal(outputPath({ ...project, sizeOverride }, name, '.gif', '/tmp/chosen.gif'), path.resolve('/tmp/chosen.gif'));
}
}
});
Expand Down
8 changes: 5 additions & 3 deletions test/phonemes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ test('maps every misaki v1.0 tie sequence and leaves language-specific IPA intac
assert.equal(mapPhonemes('कˈʌcʰcʰi'), 'कˈʌcʰcʰi');
});

test('batches text through stdin, preserves punctuation and removes language flags', async t => {
const posix = { skip: process.platform === 'win32' && 'the fake espeak-ng is a POSIX script' };

test('batches text through stdin, preserves punctuation and removes language flags', posix, async t => {
fake(t, `
if (process.argv.includes('--version')) { console.log('eSpeak NG 1.51'); process.exit(0); }
const assert = require('node:assert/strict');
Expand All @@ -49,7 +51,7 @@ test('punctuation-only text needs no program', async t => {
assert.equal(espeakVersion(), null);
});

test('missing and non-executable programs give install instructions for every platform', async t => {
test('missing and non-executable programs give install instructions for every platform', posix, async t => {
const file = fake(t, '');
for (const missing of [false, true]) {
if (missing) fs.rmSync(file);
Expand All @@ -64,7 +66,7 @@ test('missing and non-executable programs give install instructions for every pl
}
});

test('failed and incomplete output gives a friendly error', async t => {
test('failed and incomplete output gives a friendly error', posix, async t => {
const file = fake(t, 'process.stderr.write("failure"); process.exit(2);');
await assert.rejects(phonemize(['Hola'], 'es'), /could not read the text/);
fs.writeFileSync(file, '#!/usr/bin/env node\nconsole.log("one");');
Expand Down
Loading