Files
stack/packages/cli/tests/organization-imports.test.mjs

332 lines
20 KiB
JavaScript

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