← Files JUJIES ArbeitsblätterARCHIVED FILE

tests/check-package.mjs

8.8 KB · Nov 11, 2026 · 13:26 UTC

↓ Download file

import { readFile, readdir, mkdtemp, mkdir, cp, rm, writeFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import assert from 'node:assert/strict';
import { checkInteractiveMaterial } from '../skills/arbeitsblatt-html/scripts/check-interactive-material.mjs';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const examplesRoot = path.join(root, 'skills/arbeitsblatt-html/assets/beispiele');
// Authoring/test caches never belong in a distributed plugin.
async function checkCleanTree(directory) {
  for (const item of await readdir(directory,{withFileTypes:true})) {
    assert(item.name !== '__pycache__' && !item.name.endsWith('.pyc'), `Generated Python cache in package: ${item.name}`);
    if(item.isDirectory()) await checkCleanTree(path.join(directory,item.name));
  }
}
await checkCleanTree(root);
const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8'));
const nativeManifest = JSON.parse(await readFile(path.join(root, '.codex-plugin/plugin.json'), 'utf8'));
assert.equal(manifest.name, 'jujies-arbeitsblaetter');
assert.match(manifest.version, /^\d+\.\d+\.\d+$/);
assert.equal(nativeManifest.version, manifest.version);
assert.deepEqual(nativeManifest.interface, manifest.extensions['com.openai'].interface, 'Compatibility interface must match the canonical manifest before host normalization');
assert(!('interface' in nativeManifest.extensions['com.openai']), 'Compatibility interface must occur only in its native top-level location');
assert.deepEqual(nativeManifest.extensions['com.openai'].publication, manifest.extensions['com.openai'].publication, 'Publication metadata must survive host normalization unchanged');
const references = JSON.parse(await readFile(path.join(root, 'tests/originalreferenzen.json'), 'utf8'));
assert.equal(references.version, manifest.version);
const examples = (await readdir(examplesRoot)).sort();
const additions = JSON.parse(await readFile(path.join(root, 'tests/ergaenzungen-0.1.8.json'), 'utf8'));
const addedIds = additions.map(x => x.id);
const expected = ['bio-opioide-einstieg', 'hamburg-city-bikes-listening', 'immer-richtung-sueden-lesebegleitung', 'ottomotor-bauteile-funktionen', 'ottomotor-vier-takte', 'transport-announcements-listening', ...addedIds].sort();
assert.deepEqual(examples, expected);
const results = [];
for (const id of examples) {
  const sourceDirectory = path.join(examplesRoot, id);
  const result = await checkInteractiveMaterial({ sourceDirectory, requirePrint: true });
  // The active revision uses shared pages; historical entry files remain for saved instances.
  assert.equal(result.print.layout, 'shared-pages');
  assert.equal(result.entry, 'pages.html');
  assert.equal(result.print.entry, result.entry);
  const html = await readFile(path.join(sourceDirectory, result.entry), 'utf8');
  if (!addedIds.includes(id)) {
  const migration = JSON.parse(await readFile(path.join(root, 'tests/shared-pages-migration.json'), 'utf8')).find(x => x.id === id);
  assert.equal((html.match(/\bdata-tafelraum-page=/g) ?? []).length, migration.expectedPages);
  assert.equal(createHash('sha256').update(await readFile(path.join(sourceDirectory, 'index.html'))).digest('hex'), migration.legacyHtmlSha256);
  assert.equal(createHash('sha256').update(await readFile(path.join(sourceDirectory, 'pages.html'))).digest('hex'), migration.newHtmlSha256);
  assert.equal(createHash('sha256').update(await readFile(path.join(sourceDirectory, 'pages.css'))).digest('hex'), migration.newCssSha256);
  } else {
    assert.equal((html.match(/\bdata-tafelraum-page(?:=|>)/g) ?? []).length, 1);
    assert.doesNotMatch(html, /contenteditable=/);
    const added = additions.find(x => x.id === id);
    for (const file of added.files) assert.equal(createHash('sha256').update(await readFile(path.join(root, file.path))).digest('hex'), file.sha256, file.path);
  }
  const fields = [...html.matchAll(/\bid="([^"]+)"/g)].map(x => x[1]);
  assert.equal(new Set(fields).size, fields.length, `${id}: doppelte IDs`);
  assert.doesNotMatch(html, /<script\b|<iframe\b|\bchecked(?:\s|=|>)/i);
  for (const match of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/gi)) {
    const value = match[1];
    if (/^(?:https?:|#|mailto:)/i.test(value)) continue; // Source-attribution links are not runtime dependencies.
    assert.doesNotMatch(value, /^(?:\/\/|file:)/i);
    const resolved = path.resolve(sourceDirectory, value.split('#')[0]);
    assert.ok(resolved.startsWith(sourceDirectory + path.sep));
    assert.ok((await stat(resolved)).isFile(), `${id}: missing ${value}`);
  }
  assert.doesNotMatch(html, /\bsrc=["'](?:https?:|\/\/|file:)/i);
  async function checkCssTree(directory) {
    for (const entry of await readdir(directory, { withFileTypes: true })) {
      const filename = path.join(directory, entry.name);
      if (entry.isDirectory()) await checkCssTree(filename);
      else if (entry.name.endsWith('.css')) assert.doesNotMatch(await readFile(filename, 'utf8'), /@import|url\(\s*["']?(?:https?:|\/\/|file:)/i);
      assert.ok(!/^(?:LEHRKRAFT|UI_BRIEF)\.md$/.test(entry.name), `${id}: teacher file in student package`);
    }
  }
  await checkCssTree(sourceDirectory);
  results.push({ id, print: result.print, fileCount: result.fileCount });
}
for (const original of references.files) {
  const contents = await readFile(path.join(root, original.pluginRelativePath));
  assert.equal(createHash('sha256').update(contents).digest('hex'), original.pluginSha256 ?? original.sha256, original.pluginRelativePath);
}
// Independently exercise the current new-sheet contract; historical examples do not weaken it.
const scratchRoot = path.resolve(process.cwd(), 'work');
await mkdir(scratchRoot, { recursive: true });
const tmp = await mkdtemp(path.join(scratchRoot, 'plugin-tests-'));
const target = path.join(tmp, 'contract-probe');
await mkdir(target);
const probeManifest = { format: 'tafelraum-interactive-html', schemaVersion: 1, id: 'contract-probe', title: 'Paketvertragsprobe', entry: 'index.html', print: { entry: 'index.html', page: 'A4', orientation: 'portrait', mode: 'template', layout: 'shared-pages' }, defaultViewport: { width: 794, height: 1123 } };
const probeHtml = '<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Probe</title></head><body><section data-tafelraum-page="1"><label for="answer">Antwort</label><textarea id="answer"></textarea></section></body></html>';
try {
  async function reset() {
    await writeFile(path.join(target, 'manifest.json'), JSON.stringify(probeManifest));
    await writeFile(path.join(target, 'index.html'), probeHtml);
  }
  async function alterManifest(change) {
    const value = structuredClone(probeManifest); change(value);
    await writeFile(path.join(target, 'manifest.json'), JSON.stringify(value));
  }
  await reset();
  const valid = await checkInteractiveMaterial({ sourceDirectory: target, requirePrint: true });
  assert.equal(valid.print.layout, 'shared-pages');
  await alterManifest(v => { delete v.print; });
  await assert.rejects(checkInteractiveMaterial({ sourceDirectory: target, requirePrint: true }), /Druckvorlage/);
  await reset(); await alterManifest(v => { v.print.entry = 'print.html'; });
  await assert.rejects(checkInteractiveMaterial({ sourceDirectory: target, requirePrint: true }), /denselben/);
  await reset(); await writeFile(path.join(target, 'index.html'), probeHtml.replaceAll('data-tafelraum-page', 'data-other-page'));
  await assert.rejects(checkInteractiveMaterial({ sourceDirectory: target, requirePrint: true }), /data-tafelraum-page/);
  await reset(); await writeFile(path.join(target, 'index.html'), probeHtml.replace('</body>', '<script>let phase=0;</script></body>'));
  await assert.rejects(checkInteractiveMaterial({ sourceDirectory: target, requirePrint: true }), /getState/);
  await reset(); await alterManifest(v => { v.entry = '../outside.html'; });
  await assert.rejects(checkInteractiveMaterial({ sourceDirectory: target, requirePrint: true }), /ungültig/);
  // Regression: delivery documentation must not break an otherwise valid import.
  await reset();
  await writeFile(path.join(target, 'README.txt'), 'Öffnungsanleitung');
  await assert.rejects(checkInteractiveMaterial({ sourceDirectory: target, requirePrint: true }), /Dateityp.*nicht unterstützt/);
  await rm(path.join(target, 'README.txt'));
  await writeFile(path.join(tmp, 'ANLEITUNG.md'), 'Diesen Text nicht als Material importieren.');
  const withoutDeliveryDocs = await checkInteractiveMaterial({ sourceDirectory: target, requirePrint: true });
  assert.equal(withoutDeliveryDocs.id, 'contract-probe');
} finally { await rm(tmp, { recursive: true, force: true }); }
console.log(JSON.stringify({ ok: true, originalCases: 9, examples: results, verifiedSourceFiles: references.files.length, currentSharedPagesContract: true, rejectionCases: 6, version: manifest.version }, null, 2));

SHA-256: e11b5b008eb282b3cbe716dd2f6e46110443081e965d80523dcf4b550c4d3c68