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 });