crawl.ts (6018B)
1 // Node can look at the filesystem, but JS in the browser can't. 2 // This crawls the file tree under src/suites/${suite} to generate a (non-hierarchical) static 3 // listing file that can then be used in the browser to load the modules containing the tests. 4 5 import * as fs from 'fs'; 6 import * as path from 'path'; 7 8 import { loadMetadataForSuite } from '../framework/metadata.js'; 9 import { SpecFile } from '../internal/file_loader.js'; 10 import { TestQueryMultiCase, TestQueryMultiFile } from '../internal/query/query.js'; 11 import { validQueryPart } from '../internal/query/validQueryPart.js'; 12 import { TestSuiteListingEntry, TestSuiteListing } from '../internal/test_suite_listing.js'; 13 import { assert, unreachable } from '../util/util.js'; 14 15 const specFileSuffix = __filename.endsWith('.ts') ? '.spec.ts' : '.spec.js'; 16 17 async function crawlFilesRecursively(dir: string): Promise<string[]> { 18 const subpathInfo = await Promise.all( 19 (await fs.promises.readdir(dir)).map(async d => { 20 const p = path.join(dir, d); 21 const stats = await fs.promises.stat(p); 22 return { 23 path: p, 24 isDirectory: stats.isDirectory(), 25 isFile: stats.isFile(), 26 }; 27 }) 28 ); 29 30 const files = subpathInfo 31 .filter( 32 i => 33 i.isFile && 34 (i.path.endsWith(specFileSuffix) || 35 i.path.endsWith(`${path.sep}README.txt`) || 36 i.path === 'README.txt') 37 ) 38 .map(i => i.path); 39 40 return files.concat( 41 await subpathInfo 42 .filter(i => i.isDirectory) 43 .map(i => crawlFilesRecursively(i.path)) 44 .reduce(async (a, b) => (await a).concat(await b), Promise.resolve([])) 45 ); 46 } 47 48 export async function crawl( 49 suiteDir: string, 50 opts: { validate: boolean; printMetadataWarnings: boolean } | null = null 51 ): Promise<TestSuiteListingEntry[]> { 52 if (!fs.existsSync(suiteDir)) { 53 throw new Error(`Could not find suite: ${suiteDir}`); 54 } 55 56 let validateTimingsEntries; 57 if (opts?.validate) { 58 const metadata = loadMetadataForSuite(suiteDir); 59 if (metadata) { 60 validateTimingsEntries = { 61 metadata, 62 testsFoundInFiles: new Set<string>(), 63 }; 64 } 65 } 66 67 // Crawl files and convert paths to be POSIX-style, relative to suiteDir. 68 const filesToEnumerate = (await crawlFilesRecursively(suiteDir)) 69 .map(f => path.relative(suiteDir, f).replace(/\\/g, '/')) 70 .sort(); 71 72 const entries: TestSuiteListingEntry[] = []; 73 for (const file of filesToEnumerate) { 74 // |file| is the suite-relative file path. 75 if (file.endsWith(specFileSuffix)) { 76 const filepathWithoutExtension = file.substring(0, file.length - specFileSuffix.length); 77 const pathSegments = filepathWithoutExtension.split('/'); 78 79 const suite = path.basename(suiteDir); 80 81 if (opts?.validate) { 82 const filename = `../../${suite}/${filepathWithoutExtension}.spec.js`; 83 84 assert(!process.env.STANDALONE_DEV_SERVER); 85 const mod = (await import(filename)) as SpecFile; 86 assert(mod.description !== undefined, 'Test spec file missing description: ' + filename); 87 assert(mod.g !== undefined, 'Test spec file missing TestGroup definition: ' + filename); 88 89 mod.g.validate(new TestQueryMultiFile(suite, pathSegments)); 90 91 for (const { testPath } of mod.g.collectNonEmptyTests()) { 92 const testQuery = new TestQueryMultiCase(suite, pathSegments, testPath, {}).toString(); 93 if (validateTimingsEntries) { 94 validateTimingsEntries.testsFoundInFiles.add(testQuery); 95 } 96 } 97 } 98 99 for (const p of pathSegments) { 100 assert(validQueryPart.test(p), `Invalid directory name ${p}; must match ${validQueryPart}`); 101 } 102 entries.push({ file: pathSegments }); 103 } else if (path.basename(file) === 'README.txt') { 104 const dirname = path.dirname(file); 105 const readme = fs.readFileSync(path.join(suiteDir, file), 'utf8').trim(); 106 107 const pathSegments = dirname !== '.' ? dirname.split('/') : []; 108 entries.push({ file: pathSegments, readme }); 109 } else { 110 unreachable(`Matched an unrecognized filename ${file}`); 111 } 112 } 113 114 if (validateTimingsEntries) { 115 const zeroEntries = []; 116 const staleEntries = []; 117 for (const [metadataKey, metadataValue] of Object.entries(validateTimingsEntries.metadata)) { 118 if (metadataKey.startsWith('_')) { 119 // Ignore json "_comments". 120 continue; 121 } 122 if (metadataValue.subcaseMS <= 0) { 123 zeroEntries.push(metadataKey); 124 } 125 if (!validateTimingsEntries.testsFoundInFiles.has(metadataKey)) { 126 staleEntries.push(metadataKey); 127 } 128 } 129 if (zeroEntries.length && opts?.printMetadataWarnings) { 130 console.warn( 131 'WARNING: subcaseMS ≤ 0 found in listing_meta.json (see docs/adding_timing_metadata.md):' 132 ); 133 for (const metadataKey of zeroEntries) { 134 console.warn(` ${metadataKey}`); 135 } 136 } 137 138 if (opts?.printMetadataWarnings) { 139 const missingEntries = []; 140 for (const metadataKey of validateTimingsEntries.testsFoundInFiles) { 141 if (!(metadataKey in validateTimingsEntries.metadata)) { 142 missingEntries.push(metadataKey); 143 } 144 } 145 if (missingEntries.length) { 146 console.error( 147 'WARNING: Tests missing from listing_meta.json (see docs/adding_timing_metadata.md):' 148 ); 149 for (const metadataKey of missingEntries) { 150 console.error(` ${metadataKey}`); 151 } 152 } 153 } 154 155 if (staleEntries.length) { 156 console.error('ERROR: Non-existent tests found in listing_meta.json. Please update:'); 157 for (const metadataKey of staleEntries) { 158 console.error(` ${metadataKey}`); 159 } 160 unreachable(); 161 } 162 } 163 164 return entries; 165 } 166 167 export function makeListing(filename: string): Promise<TestSuiteListing> { 168 // Don't validate. This path is only used for the dev server and running tests with Node. 169 // Validation is done for listing generation and presubmit. 170 return crawl(path.dirname(filename)); 171 }