From a6e7d9571ba77fd2831953e43cc8cb86bd14de8b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 10:50:39 -0500 Subject: [PATCH 1/4] feat(cast): vendor asciinema-player v3.18.0 download script + REUSE entry - Add scripts/vendor-asciinema.mjs: ESM download script for asciinema-player v3.18.0 JS + CSS from GitHub releases; idempotent via versioned sentinel file (.asciinema-vendored-3.18.0); guarded against test import side-effects via process.argv[1] check - Add scripts/__tests__/vendor-asciinema.test.mjs: offline unit tests using vi.stubGlobal + vi.spyOn(fs); two cases: non-ok fetch exits 1, sentinel present skips download; fixed vi.stubGlobal return-value quirk by capturing mock fn before passing to stubGlobal (documented in test comment) - Update vitest.config.ts: extend unit include patterns from {js,ts} to {js,mjs,ts} so .mjs test files are picked up - Update package.json: add vendor:asciinema script; prepend to build:apps; append to setup - Update REUSE.toml: add Apache-2.0 annotation for asciinema-player.css --- REUSE.toml | 8 ++++ package.json | 5 ++- scripts/__tests__/vendor-asciinema.test.mjs | 30 ++++++++++++++ scripts/vendor-asciinema.mjs | 46 +++++++++++++++++++++ vitest.config.ts | 2 +- 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 scripts/__tests__/vendor-asciinema.test.mjs create mode 100644 scripts/vendor-asciinema.mjs diff --git a/REUSE.toml b/REUSE.toml index f3ccbd90d..40648d946 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -16,3 +16,11 @@ SPDX-FileCopyrightText = "asciinema contributors" SPDX-License-Identifier = "Apache-2.0" # NOTE: This file must be placed before deploying tutorials that use ::cast directives. # Download from https://github.com/asciinema/asciinema-player/releases + +[[annotations]] +path = "hugo/static/js/vendor/asciinema-player.css" +SPDX-FileCopyrightText = "asciinema contributors" +SPDX-License-Identifier = "Apache-2.0" +# NOTE: This file must be placed before deploying tutorials that use ::cast directives. +# Download from https://github.com/asciinema/asciinema-player/releases + diff --git a/package.json b/package.json index 5f48ac342..41f57b5c1 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "start:approuter": "cd approuter && NODE_ENV=development npm start", "bind:setup": "node scripts/setup-hybrid-env.js", "seed:thirdparty": "echo 'RETIRED: HomepageShelves (incl. third-party links) is now seeded idempotently at boot by srv/lib/homepage/seed-homepage-shelves.js from srv/lib/homepage/homepage-shelves-defaults.js. Edit that module or use /admin-ui/#homepage; no manual seed step needed.' && exit 1", - "setup": "npm --prefix hugo-apps install --no-audit --no-fund && npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/channel-atlas install --no-audit --no-fund && sh scripts/install-git-hooks.sh", + "setup": "npm --prefix hugo-apps install --no-audit --no-fund && npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/channel-atlas install --no-audit --no-fund && sh scripts/install-git-hooks.sh && npm run vendor:asciinema", "build:cds": "cds build --production", "fetch-tutorials": "tsx scripts/fetch-tutorials.ts --target hugo", "fetch-homepage-shelves": "tsx scripts/fetch-homepage-shelves.ts", @@ -83,8 +83,9 @@ "vendor:mediapipe": "node scripts/vendor-mediapipe.cjs", "vendor:imgly": "node scripts/vendor-imgly.cjs", "vendor:animegan": "node scripts/vendor-animegan.mjs", + "vendor:asciinema": "node scripts/vendor-asciinema.mjs", "clean:island-bundles": "node scripts/clean-island-bundles.cjs", - "build:apps": "npm run vendor:mediapipe && npm run vendor:imgly && npm run vendor:animegan && npm run clean:island-bundles && npm --prefix hugo-apps run build", + "build:apps": "npm run vendor:asciinema && npm run vendor:mediapipe && npm run vendor:imgly && npm run vendor:animegan && npm run clean:island-bundles && npm --prefix hugo-apps run build", "build:island-manifest": "node scripts/build-island-manifest.cjs", "check:ui5-single-copy": "node scripts/check-ui5-single-copy.cjs", "retain:assets": "node scripts/retain-asset-bundles.cjs --js-dir hugo/public/js --css-dir hugo/public/css --manifest-out hugo/public/_retained-assets.json", diff --git a/scripts/__tests__/vendor-asciinema.test.mjs b/scripts/__tests__/vendor-asciinema.test.mjs new file mode 100644 index 000000000..e0aa3b5b3 --- /dev/null +++ b/scripts/__tests__/vendor-asciinema.test.mjs @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import fs from 'node:fs'; + +// Uses vi.mock to stub fetch and fs so the test runs offline. +// NOTE: vi.stubGlobal returns the `vi` object for chaining (not the mock), +// so we capture the mock fn separately before passing it to stubGlobal. +describe('vendor-asciinema', () => { + afterEach(() => { vi.restoreAllMocks(); }); + + it('exits 1 and prints error when fetch returns non-ok', async () => { + vi.stubGlobal('fetch', async () => ({ ok: false, status: 404, statusText: 'Not Found' })); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { main } = await import('../vendor-asciinema.mjs?v=' + Date.now()); + await expect(main()).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleSpy.mock.calls.some(args => args[0].toString().includes('404'))).toBe(true); + }); + + it('skips download when sentinel file already present', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + const { main } = await import('../vendor-asciinema.mjs?v=' + Date.now()); + await main(); + expect(mockFetch).not.toHaveBeenCalled(); + existsSpy.mockRestore(); + }); +}); + diff --git a/scripts/vendor-asciinema.mjs b/scripts/vendor-asciinema.mjs new file mode 100644 index 000000000..14e439699 --- /dev/null +++ b/scripts/vendor-asciinema.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// scripts/vendor-asciinema.mjs +// Downloads asciinema-player v3.18.0 static assets from GitHub releases. +// Both files are placed in hugo/static/js/vendor/ (gitignored). +// Idempotent: skips if both files and sentinel are already present at the +// expected version. +// +// License: asciinema-player is Apache-2.0. REUSE.toml records both files. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DEST_DIR = path.resolve(__dirname, '../hugo/static/js/vendor'); +const VERSION = '3.18.0'; +const SENTINEL = path.join(DEST_DIR, `.asciinema-vendored-${VERSION}`); + +export async function main() { + if (fs.existsSync(SENTINEL)) { + console.log(`skipped (already v${VERSION})`); + return; + } + + const files = ['asciinema-player.min.js', 'asciinema-player.css']; + + for (const name of files) { + const url = `https://github.com/asciinema/asciinema-player/releases/download/v${VERSION}/${name}`; + const res = await fetch(url); + if (!res.ok) { + console.error(`Failed to download ${name}: ${res.status} ${res.statusText}`); + process.exit(1); + } + const buf = Buffer.from(await res.arrayBuffer()); + fs.writeFileSync(path.join(DEST_DIR, name), buf); + } + + fs.writeFileSync(SENTINEL, VERSION); + console.log(`vendored asciinema-player v${VERSION}`); +} + +// Only run when invoked directly (not when imported by tests) +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + main().catch(e => { console.error(e); process.exit(1); }); +} diff --git a/vitest.config.ts b/vitest.config.ts index a09d26259..5b7ee3c95 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ name: 'unit', globals: true, environment: 'node', - include: ['test/**/*.test.{js,ts}', 'scripts/__tests__/**/*.test.{js,ts}', 'scripts/**/__tests__/**/*.test.{js,ts}', 'srv/**/__tests__/**/*.test.{js,ts}', 'app/analytics-explorer/src/**/__tests__/**/*.test.ts', 'app/explore/src/**/__tests__/**/*.test.ts', 'app/channel-atlas/src/**/__tests__/**/*.test.ts', 'hugo-apps/src/**/*.{test,spec}.{js,ts}', '.claude/skills/**/__tests__/**/*.test.{js,ts}', 'hugo/assets/js/__tests__/**/*.test.{js,ts}'], + include: ['test/**/*.test.{js,mjs,ts}', 'scripts/__tests__/**/*.test.{js,mjs,ts}', 'scripts/**/__tests__/**/*.test.{js,mjs,ts}', 'srv/**/__tests__/**/*.test.{js,mjs,ts}', 'app/analytics-explorer/src/**/__tests__/**/*.test.ts', 'app/explore/src/**/__tests__/**/*.test.ts', 'app/channel-atlas/src/**/__tests__/**/*.test.ts', 'hugo-apps/src/**/*.{test,spec}.{js,ts}', '.claude/skills/**/__tests__/**/*.test.{js,mjs,ts}', 'hugo/assets/js/__tests__/**/*.test.{js,mjs,ts}'], // test/e2e/** is the Playwright-driven `e2e` project (below). Its // specs launch a real browser against a DEPLOYED approuter and would // hang the unit tier (which has no BASE_URL); the broad From ecac41105dbb75b2cf727d4737a5c3df4048d41e Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 6 Nov 2026 11:06:15 -0500 Subject: [PATCH 2/4] =?UTF-8?q?feat(cast):=20implement=20cast.ts=20?= =?UTF-8?q?=E2=80=94=20lazy-mount=20asciinema=20player=20via=20Intersectio?= =?UTF-8?q?nObserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hugo/assets/js/__tests__/cast.test.ts | 66 ++++++++++++++ hugo/assets/js/cast.ts | 118 +++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 hugo/assets/js/__tests__/cast.test.ts diff --git a/hugo/assets/js/__tests__/cast.test.ts b/hugo/assets/js/__tests__/cast.test.ts new file mode 100644 index 000000000..ddf52fd9d --- /dev/null +++ b/hugo/assets/js/__tests__/cast.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Provide a minimal AsciinemaPlayer stub on window. +const mockCreate = vi.fn(); +beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + (window as unknown as Record)['AsciinemaPlayer'] = { create: mockCreate }; + mockCreate.mockClear(); +}); + +it('mounts player on .tut-cast elements in viewport', async () => { + document.body.innerHTML = ` +
`; + const { initCast } = await import('../cast.js'); + await initCast({ skipObserver: true }); // bypass IO for unit test + expect(mockCreate).toHaveBeenCalledOnce(); + const [src, el, opts] = mockCreate.mock.calls[0]; + expect(src).toBe('https://example.com/demo.cast'); + expect(el).toBeInstanceOf(HTMLElement); + expect(opts.rows).toBe(24); + expect(opts.speed).toBe(1.5); +}); + +it('does not double-mount an already mounted element', async () => { + document.body.innerHTML = ` +
`; + const { initCast } = await import('../cast.js'); + await initCast({ skipObserver: true }); + expect(mockCreate).not.toHaveBeenCalled(); +}); + +it('falls back to immediate mount when IntersectionObserver absent', async () => { + document.body.innerHTML = ` +
`; + // Remove IO from window to simulate absent support. + const origIO = (window as unknown as Record)['IntersectionObserver']; + delete (window as unknown as Record)['IntersectionObserver']; + const { initCast } = await import('../cast.js'); + await initCast({}); + expect(mockCreate).toHaveBeenCalledOnce(); + (window as unknown as Record)['IntersectionObserver'] = origIO; +}); + +it('passes idle-limit as idleTimeLimit to player', async () => { + document.body.innerHTML = ` +
`; + const { initCast } = await import('../cast.js'); + await initCast({ skipObserver: true }); + const [, , opts] = mockCreate.mock.calls[0]; + expect(opts.idleTimeLimit).toBe(2); +}); + +it('removes the placeholder pre after mounting', async () => { + document.body.innerHTML = ` +
+
Loading…
+
`; + const { initCast } = await import('../cast.js'); + await initCast({ skipObserver: true }); + expect(document.querySelector('.tut-cast__placeholder')).toBeNull(); +}); diff --git a/hugo/assets/js/cast.ts b/hugo/assets/js/cast.ts index cb0ff5c3b..941f6a12a 100644 --- a/hugo/assets/js/cast.ts +++ b/hugo/assets/js/cast.ts @@ -1 +1,117 @@ -export {}; +// cast.ts — lazy-mount asciinema player on .tut-cast[data-tut-cast] elements. +// The asciinema-player is vendored as an IIFE (sets window.AsciinemaPlayer), +// so we inject a