tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

modules.test.mjs (8953B)


      1 import test from 'node:test';
      2 import assert from 'node:assert/strict';
      3 import {readFile, readdir} from 'node:fs/promises';
      4 import {createHash} from 'node:crypto';
      5 import {decodeBundle, sha256} from '../dist/bundle.js';
      6 import {indexedObject, moduleIdentity, moduleHash} from '../dist/module-identity.js';
      7 import {fetchModule} from '../dist/module-resolver.js';
      8 import {packageIndex, packageReleases} from '../dist/packages.js';
      9 const site = new URL('../', import.meta.url), root = new URL('../../', site);
     10 const releases = packageIndex(JSON.parse(await readFile(new URL('packages.json', site))));
     11 const bytes = r => readFile(new URL(`bundles/${r.hash}.arboricx`, site));
     12 const libraries = [...new Set(releases.filter(r => r.name.startsWith('tricu-lib-')).map(r => r.name))]
     13   .map(name => packageReleases(releases, name)[0]);
     14 
     15 test('every library source has a complete-interface package; no module allowlist', async () => {
     16   const paths = [];
     17   async function walk(directory) {
     18     for (const entry of await readdir(new URL(directory, root), {withFileTypes:true})) {
     19       const path = directory + entry.name;
     20       if (entry.isDirectory()) await walk(path + '/');
     21       else if (entry.name.endsWith('.tri')) paths.push(path);
     22     }
     23   }
     24   await walk('lib/');
     25   // Historical package names remain indexed when an authored workspace alias
     26   // changes. Check every source and its current alias, not an allowlist or the
     27   // first historical release carrying the same source-path title.
     28   // Immutable releases can outlive their source filenames. Only the explicit
     29   // historical IO paths are absent; every current source still needs its exact
     30   // authored alias and independently checked full interface below.
     31   const historicalPaths = new Set(['lib/io-v2.tri','lib/io-v2/artifacts.tri',
     32     'lib/io-v2/http-transport.tri','lib/io-v2/objects.tri','lib/io-v2/storage.tri',
     33     'lib/io-v2/text.tri','lib/source/io-v2.tri','lib/arboricx/streaming-v2.tri',
     34     'lib/packages/io-v2.tri']);
     35   assert.deepEqual([...new Set(libraries.filter(r => !historicalPaths.has(r.title)).map(r => r.title))].sort(), paths.sort());
     36   const workspace=(await readFile(new URL('tricu.workspace',root),'utf8')).trimEnd().split('\n');
     37   assert.equal(workspace.shift(),'tricu.workspace.v1');
     38   const aliases=new Map(workspace.map(row=>{const [alias,path]=row.split('\t');return [path,alias];}));
     39   const currentLibrary=path=>libraries.find(r=>r.name==='tricu-lib-'+
     40     (aliases.get(path)??path.slice(4,-4).replaceAll('/','_').replaceAll('-','_')).replaceAll('_','-'));
     41   for(const path of paths){const release=currentLibrary(path);assert.ok(release,path);assert.equal(release.title,path);}
     42   for (const cohort of ['compiler-library', 'support-library']) {
     43     // The aggregate native support fixture exceeds the browser's per-module
     44     // node bound. Compare its independently authenticated native manifests,
     45     // not a relaxed browser decoder. Every actual published module below still
     46     // passes the unchanged browser transport/graph bounds.
     47     const fixtureBytes = await readFile(new URL(`test/fixtures/${cohort}.arboricx`, root));
     48     const fixture = cohort === 'compiler-library' ? decodeBundle(fixtureBytes) : null;
     49     if (!fixture) assert.throws(() => decodeBundle(fixtureBytes), /Bundle node count limit exceeded/);
     50     const metadata = await readFile(new URL(`test/fixtures/${cohort}.v1`, root), 'utf8');
     51     for (const row of metadata.split('\n').filter(s => s.startsWith('module\t'))) {
     52       const [, , alias, path] = row.split('\t');
     53       if (!path.startsWith('lib/')) continue;
     54       const release = currentLibrary(path);
     55       assert.ok(release, path);
     56       const bundle = decodeBundle(await bytes(release));
     57       let expected;
     58       if (fixture) expected = fixture.exports.filter(e => e.name.startsWith(alias + '.')).map(e => e.name.slice(alias.length + 1));
     59       else {
     60         const manifest = await readFile(new URL(`test/fixtures/support-receipts/${alias}.manifest`, root));
     61         const record = metadata.split('\n').find(line => line.startsWith(`rebuilt\t${alias}\t`)).split('\t');
     62         assert.equal(createHash('sha256').update('arboricx.module-manifest.v1\0').update(manifest).digest('hex'), record[3]);
     63         const rows = manifest.toString('utf8').trimEnd().split('\n'); assert.equal(rows.shift(), 'arboricx.module-manifest.v1');
     64         expected = rows.map(line => { const fields = line.split('\t'); assert.equal(fields.length, 5); assert.equal(fields[0], 'export'); return fields[1]; });
     65       }
     66       assert.deepEqual(bundle.exports.map(e => e.name), expected, path);
     67     }
     68   }
     69 });
     70 
     71 test('browser manifest identities match every independently native-published library interface', async () => {
     72   assert.ok(libraries.length > 0);
     73   for (const release of libraries) {
     74     const data = await bytes(release);
     75     assert.equal(await sha256(data), release.hash, release.name);
     76     assert.equal(await moduleIdentity(decodeBundle(data)), release.manifest, release.name);
     77   }
     78 });
     79 
     80 test('indexed extraction is name-neutral, ignores unrelated roots/metadata, preserves physical sharing', async () => {
     81   const b = decodeBundle(await readFile(new URL('test/fixtures/id.arboricx', root)));
     82   const expected = await sha256(indexedObject(b, b.exports[0].root));
     83   b.metadata = {description:'not executable identity'};
     84   b.exports[0].name = 'renamed';
     85   b.nodes.push({kind:'fork',left:0,right:0});
     86   assert.equal(await sha256(indexedObject(b, b.exports[0].root)), expected);
     87   const shared = {nodes:[{kind:'leaf'}],roots:[31],exports:[{name:'tree',root:31,kind:'term',abi:'arboricx.abi.tree.v1'}],metadata:{}};
     88   for(let i=1;i<=31;i++) shared.nodes.push({kind:'fork',left:i-1,right:i-1});
     89   const object = indexedObject(shared,31);
     90   assert.ok(object.length < 2048);
     91   assert.equal(decodeBundle(object).nodes.length,32);
     92   assert.equal((await moduleIdentity(shared)).length,64);
     93   await assert.rejects(moduleIdentity({...shared,exports:[]}), /nonempty native/);
     94   await assert.rejects(moduleIdentity({...shared,exports:[{...shared.exports[0],name:'bad name'}]}), /native module/);
     95   assert.throws(()=>moduleHash('../path'), /hash/);
     96 });
     97 
     98 test('manifest transport rejects unknown refs, wrong manifest, corrupt bytes and HTTP failure', async () => {
     99   const release = libraries.find(r => r.title === 'lib/packages/io.tri');
    100   const data = await bytes(release), calls = [], original = globalThis.fetch;
    101   let response = () => new Response(data);
    102   globalThis.fetch = async (url, options) => { calls.push({url:String(url),options}); return response(); };
    103   const base = new URL('https://example.invalid/nested/site/');
    104   try {
    105     const loaded = await fetchModule(base, release.manifest, releases);
    106     assert.equal(loaded.exports.length,6);
    107     assert.equal(calls[0].url, `https://example.invalid/_arboricx/bundle/hash/${release.hash}`);
    108     assert.deepEqual(calls[0].options, {redirect:'error',credentials:'omit'});
    109     const count = calls.length;
    110     await assert.rejects(fetchModule(base, '0'.repeat(64), releases), /Unknown module manifest/);
    111     await assert.rejects(fetchModule(base, release.hash, releases), /Unknown module manifest/);
    112     assert.equal(calls.length,count);
    113     await assert.rejects(fetchModule(base, '0'.repeat(64), [{...release,manifest:'0'.repeat(64)}]), /Module manifest mismatch/);
    114     response = () => new Response(new Uint8Array([1,2,3]));
    115     await assert.rejects(fetchModule(base, release.manifest, releases), /SHA-256 mismatch/);
    116     response = () => new Response('missing',{status:404});
    117     await assert.rejects(fetchModule(base, release.manifest, releases), /HTTP 404/);
    118     response = () => new Response(data);
    119     assert.equal((await fetchModule(base, release.manifest, releases)).exports.length,6);
    120   } finally { globalThis.fetch = original; }
    121 });
    122 
    123 test('native-parsed export projection retains opaque trees without evaluating them', async () => {
    124   const {selectModuleExports} = await import('../dist/module-stage.js');
    125   const value = new Proxy({}, {get(){throw Error('Executable tree must remain opaque');}});
    126   const locals = new Map([['private', value], ['answer', value]]);
    127   const dependencies = new Map([['pin', new Map([['id', value]])]]);
    128   const stage = exports => ({body:'',imports:[],exports,native:true});
    129   assert.deepEqual([...selectModuleExports(stage([['answer','']]),locals,dependencies).keys()],['answer']);
    130   assert.deepEqual([...selectModuleExports(stage([['*',''],['from','pin']]),locals,dependencies).keys()],['private','answer','id']);
    131   for(const [exports,pattern] of [[[],/requires an export/],[[['absent','']],/undeclared local/],[[['answer',''],['*','']],/duplicate export/],[[['from','missing']],/Missing re-export/]]){
    132     assert.throws(()=>selectModuleExports(stage(exports),locals,dependencies),pattern);
    133   }
    134   const huge = new Map(Array.from({length:4097},(_,i)=>['n'+i,value]));
    135   assert.throws(()=>selectModuleExports(stage([['*','']]),huge,dependencies),/export limit/);
    136 });