// Vikunja #779: the organization-imports helper (business-imports before rename r2 stage B) on // synthetic trees. Every tree is built under a private temp directory with the repository one level // down, so a test can put a file outside the repository. Each tree has both package roots: // packages/organization/src holds the file under test and packages/business/src, the shim root, is // empty unless a test fills it. import test, {after} from 'node:test'; import assert from 'node:assert/strict'; import {mkdirSync,mkdtempSync,rmSync,symlinkSync,writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {dirname,join} from 'node:path'; import {fileURLToPath} from 'node:url'; import {organizationImportedFiles} from './organization-imports.mjs'; import * as oldName from './business-imports.mjs'; const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,''); const base=mkdtempSync(join(tmpdir(),'bi-')); after(()=>rmSync(base,{recursive:true,force:true})); const ORG='repo/packages/organization/src/',SHIM='repo/packages/business/src/',EXT='repo/packages/other/src/',EMPTY='export {};\n'; let count=0; // files: path under the tree's top directory -> text. links: path -> target path, both under it. function tree(files,links={},{roots=[ORG,SHIM]}={}){ const top=join(base,`t${++count}`); for(const r of roots)mkdirSync(join(top,r),{recursive:true}); for(const [path,text] of Object.entries(files)){mkdirSync(dirname(join(top,path)),{recursive:true});writeFileSync(join(top,path),text);} for(const [path,target] of Object.entries(links)){mkdirSync(dirname(join(top,path)),{recursive:true});symlinkSync(join(top,target),join(top,path));} return join(top,'repo'); } const withOrg=(text,extra={})=>tree({[`${ORG}a.mjs`]:text,...extra}); test('the real tree gives the plug-in files the loader reads, and nothing inside either root', ()=>{ const found=organizationImportedFiles(ROOT); assert.ok(found.includes('packages/plugins/src/kinds.mjs'),found.join(' ')); assert.deepEqual(found.filter((f)=>f.startsWith('packages/organization/src/')||f.startsWith('packages/business/src/')),[]); assert.ok(found.includes('packages/plugins/src/model-quota.mjs'),found.join(' ')); assert.deepEqual(found,[...found].sort()); }); test('the loader exception holds only for the loader file and its exact computed import', ()=>{ const text="const module=await import(pathToFileURL(expected).href);\n"; const at=(file)=>tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",[`repo/${file}`]:text}); assert.deepEqual(organizationImportedFiles(at('packages/plugins/src/loader.mjs')),['packages/plugins/src/loader.mjs']); assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/other.mjs';\n",'repo/packages/plugins/src/other.mjs':text})),/not a plain string literal/); assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",'repo/packages/plugins/src/loader.mjs':"await import(x);\n"})),/not a plain string literal/); }); test('PL1 pinned import exception holds only for the checked loader entry and exact hash cache key',()=>{ const computed="await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256);\n"; const at=(file,text=computed)=>tree({[`${ORG}a.mjs`]:`import '../../plugins/src/${file}';\n`,[`repo/packages/plugins/src/${file}`]:text}); assert.deepEqual(organizationImportedFiles(at('loader.mjs')),['packages/plugins/src/loader.mjs']); assert.throws(()=>organizationImportedFiles(at('other.mjs')),/not a plain string literal/); for(const changed of [computed.replace('entry','arbitrary'),computed.replace('?pin=','?unchecked='),computed.replace('checked.manifest.sha256','input')])assert.throws(()=>organizationImportedFiles(at('loader.mjs',changed)),/not a plain string literal/); }); test('every static form and a literal dynamic import is found, and the imports of what it finds in turn', ()=>{ const repo=withOrg([ "import x from '../../other/src/from.mjs';", "import {\n y,\n z,\n} from '../../other/src/multi.mjs';", "import '../../other/src/bare.mjs';", "export * from '../../other/src/reexport.mjs';", "export {w} from '../../other/src/named.mjs';", "const d=await import('../../other/src/dynamic.mjs');", "const n=await import(\n '../../other/src/newline.mjs'\n);", ].join('\n'),{ [`${EXT}from.mjs`]:"import '../../third/src/deep.mjs';\n", 'repo/packages/third/src/deep.mjs':EMPTY, ...Object.fromEntries(['multi','bare','reexport','named','dynamic','newline'].map(n=>[`${EXT}${n}.mjs`,EMPTY])), }); assert.deepEqual(organizationImportedFiles(repo),[ ...['bare','dynamic','from','multi','named','newline','reexport'].map(n=>`packages/other/src/${n}.mjs`), 'packages/third/src/deep.mjs', ]); }); test('#779 a dynamic import written with a space or a line break before the bracket is found', ()=>{ const repo=withOrg("const s=await import ('../../other/src/spaced.mjs');\nconst t=await import\n ('../../other/src/broken.mjs');\n", {[`${EXT}spaced.mjs`]:EMPTY,[`${EXT}broken.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/broken.mjs','packages/other/src/spaced.mjs']); }); test('#779 an import in a line or block comment is not an import, and one after a comment is', ()=>{ const repo=withOrg([ "// import '../../other/src/missing1.mjs';", "/* import x from '../../other/src/missing2.mjs'; */", "/*\n import('../../other/src/missing3.mjs')\n*/", "const x=1; // import '../../other/src/missing4.mjs'", "import '../../other/src/real.mjs'; // import('../../other/src/missing5.mjs')", "// import(name)", "/* import(name) */ import '../../other/src/after.mjs';", ].join('\n'),{[`${EXT}real.mjs`]:EMPTY,[`${EXT}after.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/after.mjs','packages/other/src/real.mjs']); }); test('#779 comment markers inside strings, templates and regular expressions do not hide an import', ()=>{ const repo=withOrg([ "const url='http://example.invalid/a'; import '../../other/src/after-url.mjs';", "const glob=\"/* open\"; import '../../other/src/after-glob.mjs';", "const tpl=`// not a comment ${1}`; import '../../other/src/after-template.mjs';", "const re=/['\"]\\/\\/x/; import '../../other/src/after-regex.mjs';", "const half=a / 2;\nconst mid=(a+b) / 2;\nimport '../../other/src/after-divide.mjs';", ].join('\n'),Object.fromEntries(['url','glob','template','regex','divide'].map(n=>[`${EXT}after-${n}.mjs`,EMPTY]))); assert.deepEqual(organizationImportedFiles(repo),['url','glob','template','regex','divide'].map(n=>`packages/other/src/after-${n}.mjs`).sort()); }); test('#779 an import form inside a string or a regular expression is not an import', ()=>{ const repo=withOrg([ "const a=\"import '../../other/src/missing1.mjs'\";", "const b='import(name)';", "const c=/import (name)/;", "const d=\"export {x} from '../../other/src/missing2.mjs'\";", "import '../../other/src/real.mjs';", ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); }); test('#779 a hashbang line is skipped and a bare slash in it does not start a regular expression', ()=>{ const repo=withOrg("#!/usr/bin/env node\nimport '../../other/src/real.mjs';\n",{[`${EXT}real.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); const bare=withOrg("#!/odd\nimport '../../other/src/real.mjs';\n",{[`${EXT}real.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(bare),['packages/other/src/real.mjs']); }); test('#779 a dynamic import of anything but a plain string throws, and what only looks like one does not', ()=>{ for(const argument of ['name',"'../../other/src/' + name",'`../../other/src/${name}.mjs`',"'../../other/src/a.mjs', {with:{type:'json'}}","'../../other/src/a.mjs' + ''"]){ const repo=withOrg(`const m=await import(${argument});\n`,{[`${EXT}a.mjs`]:EMPTY}); assert.throws(()=>organizationImportedFiles(repo),/organization\/src\/a\.mjs has a dynamic import that is not a plain string literal/,argument); } const repo=withOrg([ 'const u=import.meta.url;','loader.import(thing);','reimport(thing);','$import(thing);', "const fs=await import('node:fs');","const s='import(name)';", ].join('\n')); assert.deepEqual(organizationImportedFiles(repo),[]); }); test('#779 a file the scanner cannot read to its end throws and names the file, in a root or external', ()=>{ for(const [what,text] of [['string',"const a='open;\n"],['string','const a="open'],['comment',"/* open\nimport '../../other/src/a.mjs';\n"],['template','const a=`open'],['regular expression','const a=/open\n']]){ const repo=withOrg(text,{[`${EXT}a.mjs`]:EMPTY}); assert.throws(()=>organizationImportedFiles(repo),new RegExp(`packages/organization/src/a\\.mjs cannot be read for imports: unterminated ${what}`),text); } const repo=withOrg("import '../../other/src/a.mjs';\n",{[`${EXT}a.mjs`]:"/* never closed\n"}); assert.throws(()=>organizationImportedFiles(repo),/packages\/other\/src\/a\.mjs cannot be read for imports: unterminated comment/); }); test('#779 an import of a file that is missing throws, inside a package root or out of it', ()=>{ const inside=withOrg("import './missing.mjs';\n"); assert.throws(()=>organizationImportedFiles(inside),/packages\/organization\/src\/a\.mjs imports \.\/missing\.mjs, which does not exist/); const outside=withOrg("import '../../other/src/missing.mjs';\n"); assert.throws(()=>organizationImportedFiles(outside),/imports \.\.\/\.\.\/other\/src\/missing\.mjs, which does not exist/); const broken=tree({[`${ORG}a.mjs`]:"import '../../other/src/link.mjs';\n"},{[`${EXT}link.mjs`]:'gone.mjs'}); assert.throws(()=>organizationImportedFiles(broken),/which does not exist/); const beyond=withOrg("import '../../other/src/a.mjs/b.mjs';\n",{[`${EXT}a.mjs`]:EMPTY}); assert.throws(()=>organizationImportedFiles(beyond),/which does not exist/); }); test('#779 an import of a directory throws', ()=>{ const repo=withOrg("import '../../other/src/dir';\n",{[`${EXT}dir/index.mjs`]:EMPTY}); assert.throws(()=>organizationImportedFiles(repo),/imports \.\.\/\.\.\/other\/src\/dir, which is not a regular file/); }); test('#779 a target outside the repository throws, by a path that climbs out or by a symlink', ()=>{ const climbs=tree({[`${ORG}a.mjs`]:"import '../../../../outside.mjs';\n",'outside.mjs':EMPTY}); assert.throws(()=>organizationImportedFiles(climbs),/imports \.\.\/\.\.\/\.\.\/\.\.\/outside\.mjs, which is outside the repository/); const sibling=tree({[`${ORG}a.mjs`]:"import '../../../../repo2/x.mjs';\n",'repo2/x.mjs':EMPTY}); assert.throws(()=>organizationImportedFiles(sibling),/which is outside the repository/); const linked=tree({[`${ORG}a.mjs`]:"import '../../other/src/link.mjs';\n",'outside.mjs':EMPTY},{[`${EXT}link.mjs`]:'outside.mjs'}); assert.throws(()=>organizationImportedFiles(linked),/imports \.\.\/\.\.\/other\/src\/link\.mjs, which is outside the repository/); const folder=tree({[`${ORG}a.mjs`]:"import '../../other/src/out/x.mjs';\n",'outside/x.mjs':EMPTY},{[`${EXT}out`]:'outside'}); assert.throws(()=>organizationImportedFiles(folder),/which is outside the repository/); }); test('#779 a symlink that stays inside the repository, and a repository reached by a symlink, are fine', ()=>{ const repo=tree({[`${ORG}a.mjs`]:"import '../../other/src/link.mjs';\n",'repo/packages/third/real.mjs':EMPTY},{[`${EXT}link.mjs`]:'repo/packages/third/real.mjs'}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/link.mjs']); const via=join(base,`via${++count}`); symlinkSync(repo,via); assert.deepEqual(organizationImportedFiles(via),['packages/other/src/link.mjs']); }); const SUBSTITUTION=/packages\/organization\/src\/a\.mjs cannot be read for imports: unsupported import inside a template substitution/; test('#779 R2 a dynamic import inside a template substitution throws, whether the target exists, is missing or is outside', ()=>{ const exists=withOrg("const v=`${await import('../../other/src/real.mjs')}`;\n",{[`${EXT}real.mjs`]:EMPTY}); assert.throws(()=>organizationImportedFiles(exists),SUBSTITUTION); const missing=withOrg("const v=`${await import('../../other/src/real.mjs')}`;\n"); assert.throws(()=>organizationImportedFiles(missing),SUBSTITUTION); const outside=tree({[`${ORG}a.mjs`]:"const v=`${await import('../../../../outside.mjs')}`;\n",'outside.mjs':EMPTY}); assert.throws(()=>organizationImportedFiles(outside),SUBSTITUTION); }); test('#779 R2 a dynamic import inside a substitution throws however the substitution is written', ()=>{ for(const source of [ "const v=`a ${b} ${await import ('../../other/src/real.mjs')} c`;", "const v=`${await import(name)}`;", "const v=`${/* c */ import('../../other/src/real.mjs') /* d */}`;", "const v=`x ${`y ${await import('../../other/src/real.mjs')}`}`;", "const v=`${ {a:1}.a + '}' + (await import('../../other/src/real.mjs')) }`;", "const v=`${\n await import(\n '../../other/src/real.mjs'\n )\n}`;", ]){ const repo=withOrg(`${source}\n`,{[`${EXT}real.mjs`]:EMPTY}); assert.throws(()=>organizationImportedFiles(repo),SUBSTITUTION,source); } const external=withOrg("import '../../other/src/a.mjs';\n",{[`${EXT}a.mjs`]:"const v=`${await import('./b.mjs')}`;\n",[`${EXT}b.mjs`]:EMPTY}); assert.throws(()=>organizationImportedFiles(external),/packages\/other\/src\/a\.mjs cannot be read for imports: unsupported import inside a template substitution/); }); test('#779 R2 a substitution without a dynamic import is allowed, and the import around it is found', ()=>{ const repo=withOrg([ "const a=`plain ${b} text ${c.d({e:1})} end`;", "const u=`${import.meta.url}/${x.import}/${loader.import('y')}`;", "const s=`${'}'}${\"import('../../other/src/missing1.mjs')\"}${/import('..\\/x')/.source}`;", "const t=`text import('../../other/src/missing2.mjs') ${a} import '../../other/src/missing3.mjs'`;", "const e=`\\${await import('../../other/src/missing4.mjs')}`;", "const n=`outer ${`inner ${a}`} tail`;", "import '../../other/src/real.mjs';", ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); }); test('#779 R2 a substitution or the template text after it that never ends throws', ()=>{ for(const text of ['const a=`${b','const a=`${b} open','const a=`${`${b}']){ const repo=withOrg(text); assert.throws(()=>organizationImportedFiles(repo),/packages\/organization\/src\/a\.mjs cannot be read for imports: unterminated template/,text); } }); // Each case is its own test, so one failing case does not hide the next. for(const [what,make] of [ ['an existing target',()=>withOrg("const v=`${({...import('../../other/src/real.mjs')})}`;\n",{[`${EXT}real.mjs`]:EMPTY})], ['a missing target',()=>withOrg("const v=`${({...import('../../other/src/real.mjs')})}`;\n")], ['a target outside the repository',()=>tree({[`${ORG}a.mjs`]:"const v=`${({...import('../../../../outside.mjs')})}`;\n",'outside.mjs':EMPTY})], ['an array spread of an existing target',()=>withOrg("const v=`${[...import('../../other/src/real.mjs')]}`;\n",{[`${EXT}real.mjs`]:EMPTY})], ]){ test(`#779 R3 a spread before import inside a template substitution throws: ${what}`,()=>{ assert.throws(()=>organizationImportedFiles(make()),SUBSTITUTION); }); } for(const [what,source,files] of [ ['object spread',"const o={...import('../../other/src/real.mjs')};",{[`${EXT}real.mjs`]:EMPTY}], ['array spread',"const l=[...import('../../other/src/real.mjs')];",{[`${EXT}real.mjs`]:EMPTY}], ]){ test(`#779 R3 a spread before import in plain code is a dynamic import and is found when its argument is a plain string: ${what}`,()=>{ assert.deepEqual(organizationImportedFiles(withOrg(`${source}\n`,files)),['packages/other/src/real.mjs']); }); } test('#779 R3 a spread before import in plain code reaches the target checks: a missing target throws', ()=>{ const missing=withOrg("const o={...import('./missing.mjs')};\n"); assert.throws(()=>organizationImportedFiles(missing),/packages\/organization\/src\/a\.mjs imports \.\/missing\.mjs, which does not exist/); }); for(const source of ['const o={...import(name)};','const l=[...import(name)];']){ test(`#779 R3 a spread before import in plain code is refused when its argument is not a plain string: ${source}`,()=>{ const repo=withOrg(`${source}\n`); assert.throws(()=>organizationImportedFiles(repo),/organization\/src\/a\.mjs has a dynamic import that is not a plain string literal/); }); } test('#779 R3 a property call of import, x.import or x?.import, in plain code is not an import', ()=>{ const repo=withOrg([ "loader.import('../../other/src/missing1.mjs');", "loader?.import('../../other/src/missing2.mjs');", 'loader.import(name);', 'loader?.import(name);', "import '../../other/src/real.mjs';", ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); }); test('#779 R3 a property call of import, x.import or x?.import, in a substitution is not an import and is not refused', ()=>{ const repo=withOrg([ "const u=`${loader.import('../../other/src/missing3.mjs')}${loader?.import('../../other/src/missing4.mjs')}`;", 'const w=`${loader.import(name)}${loader?.import(name)}`;', "import '../../other/src/real.mjs';", ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); }); test('#779 R2 a slash after a postfix ++ or -- is division, not a regular expression', ()=>{ for(const op of ['++','--']){ const repo=withOrg([ 'let n=2;', `const half=n${op} / 2;`, `const spaced=n ${op}/2;`, `const member=arr[0]${op} / 2;`, `const grouped=(n)${op} / 2 / 3;`, "import '../../other/src/real.mjs';", ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs'],op); } }); test('#779 R2 a regular expression after an operator, a bracket or a prefix ++ or -- is still a regular expression', ()=>{ // Each body holds a quote, which would start an unterminated string if the slash were read as division. const repo=withOrg([ "const a = /it's/;", "if (/it's/.test(s)) {}", "const b = f(1, /it's \\/ x/);", "const c = a + /it's/.source;", "const d = a++ + /it's/.source;", "const e = ++/it's/.lastIndex;", "const f = 1 + --/it's/.lastIndex;", "import '../../other/src/real.mjs';", ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); }); // Rename r2 stage B: the walker reads both roots, the implementation and its old-path shims. test('rename r2 B: a shim that re-exports the implementation adds nothing, and an outside import from a shim is found', ()=>{ const repo=tree({ [`${ORG}a.mjs`]:"import '../../other/src/from-org.mjs';\n", [`${SHIM}a.mjs`]:"export * from '../../organization/src/a.mjs';\n", [`${SHIM}b.mjs`]:"import '../../other/src/from-shim.mjs';\n", [`${EXT}from-org.mjs`]:EMPTY,[`${EXT}from-shim.mjs`]:EMPTY, }); assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/from-org.mjs','packages/other/src/from-shim.mjs']); }); test('rename r2 B: a shim that imports a missing implementation file throws and names the shim', ()=>{ const repo=tree({[`${SHIM}a.mjs`]:"export * from '../../organization/src/gone.mjs';\n"}); assert.throws(()=>organizationImportedFiles(repo),/packages\/business\/src\/a\.mjs imports \.\.\/\.\.\/organization\/src\/gone\.mjs, which does not exist/); }); test('rename r2 B: a tree without either root throws, so half a package is never walked as a whole one', ()=>{ assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:EMPTY},{},{roots:[ORG]})),/ENOENT.*packages\/business\/src/); assert.throws(()=>organizationImportedFiles(tree({[`${SHIM}a.mjs`]:EMPTY},{},{roots:[SHIM]})),/ENOENT.*packages\/organization\/src/); }); test('rename r2 B: business-imports.mjs is the old name of the same walker', ()=>{ assert.equal(oldName.businessImportedFiles,organizationImportedFiles); assert.equal(oldName.organizationImportedFiles,organizationImportedFiles); });