Skip to content

Commit b198081

Browse files
committed
feat: bundle fixtures dir when file ops are used, add readdirSync
1 parent 45c681e commit b198081

2 files changed

Lines changed: 79 additions & 35 deletions

File tree

bundler/bundle.js

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from 'node:assert/strict'
2-
import { readFile } from 'node:fs/promises'
2+
import { readFile, readdir } from 'node:fs/promises'
33
import { existsSync } from 'node:fs'
44
import { fileURLToPath, pathToFileURL } from 'node:url'
55
import { basename, dirname, extname, resolve, join, relative } from 'node:path'
@@ -175,11 +175,15 @@ export const build = async (...files) => {
175175

176176
const fsfiles = await getPackageFiles(filename ? dirname(resolve(filename)) : process.cwd())
177177
const fsFilesContents = new Map()
178+
const fsFilesDirs = new Map()
178179
const cwd = process.cwd()
179-
const fsFilesAdd = async (fileRelative) => {
180-
if (!fileRelative || !/^[a-z0-9@_./-]+$/iu.test(fileRelative)) return
181-
const file = resolve(fileRelative)
182-
if (!file.startsWith(`${cwd}/`)) return
180+
const fixturesRegex = /fixtures/u
181+
const aggressiveExtensions = /\.(json|txt|hex)$/u // These are bundled when just used in path.join and by wildcard from fixtures/
182+
const fileAllowed = (f) =>
183+
f && f.startsWith(`${cwd}/`) && resolve(f) === f && /^[a-z0-9@_./-]+$/iu.test(relative(cwd, f))
184+
185+
const fsFilesAdd = async (file) => {
186+
if (!fileAllowed(file)) return
183187
try {
184188
const data = await readFile(file, 'base64')
185189
if (fsFilesContents.has(file)) {
@@ -192,19 +196,46 @@ export const build = async (...files) => {
192196
}
193197
}
194198

199+
const fixturesSeen = { fs: false, fixtures: false, bundled: false }
200+
const fsFilesBundleFixtures = async (reason) => {
201+
if (fixturesSeen.bundled || !filename) return
202+
if (reason === 'fs' || reason === 'fixtures') fixturesSeen[reason] = true
203+
if (!fixturesSeen.fs || !fixturesSeen.fixtures) return
204+
fixturesSeen.bundled = true
205+
const dir = dirname(resolve(filename))
206+
for (const name of await readdir(dir, { recursive: true })) {
207+
const parent = dirname(name)
208+
if (!fixturesRegex.test(parent)) continue // relative dir path should look like a fixtures dir
209+
// Save as directiry entry into parent dir
210+
const subdir = resolve(dir, parent)
211+
if (fileAllowed(subdir)) {
212+
if (!fsFilesDirs.has(subdir)) fsFilesDirs.set(subdir, [])
213+
fsFilesDirs.get(subdir).push(basename(name))
214+
}
215+
216+
// Save to files
217+
const file = resolve(dir, name)
218+
if (aggressiveExtensions.test(file)) await fsFilesAdd(file)
219+
}
220+
}
221+
195222
specificLoadPipeline.push(async (source, filepath) => {
196223
for (const m of source.matchAll(/readFileSync\(\s*(?:"([^"\\]+)"|'([^'\\]+)')[),]/gu)) {
197-
await fsFilesAdd(m[1] || m[2])
224+
await fsFilesAdd(resolve(m[1] || m[2])) // resolves from cwd
198225
}
199226

200227
// E.g. path.join(import.meta.dirname, './fixtures/data.json'), dirname is inlined by loadPipeline already
201228
const dir = dirname(filepath)
202229
for (const m of source.matchAll(/join\(\s*("[^"\\]+"),\s*(?:"([^"\\]+)"|'([^'\\]+)')\s*\)/gu)) {
203230
if (m[1] !== JSON.stringify(dir)) continue // only allow files relative to dirname, from loadPipeline
204-
const file = relative(cwd, join(dir, m[2] || m[3]))
205-
if (/\.(json|txt|hex)$/u.test(file)) await fsFilesAdd(file) // only allow specific extensions used as test fixtures
231+
const file = resolve(dir, m[2] || m[3])
232+
if (aggressiveExtensions.test(file)) await fsFilesAdd(file) // only bundle path.join for specific extensions used as test fixtures
206233
}
207234

235+
// Both conditions should happen for deep fixtures inclusion
236+
if (/(readdir|readFile|exists)Sync/u.test(source)) await fsFilesBundleFixtures('fs')
237+
if (fixturesRegex.test(source)) await fsFilesBundleFixtures('fixtures')
238+
208239
return source
209240
})
210241

@@ -284,6 +315,7 @@ export const build = async (...files) => {
284315
EXODUS_TEST_RECORDINGS: stringify(EXODUS_TEST_RECORDINGS),
285316
EXODUS_TEST_FSFILES: stringify(fsfiles), // TODO: can we safely use relative paths?
286317
EXODUS_TEST_FSFILES_CONTENTS: stringify([...fsFilesContents.entries()]),
318+
EXODUS_TEST_FSDIRS: stringify([...fsFilesDirs.entries()]),
287319
},
288320
alias: {
289321
// Jest, tape and node:test
@@ -352,9 +384,10 @@ export const build = async (...files) => {
352384
let res = await buildWrap(config)
353385
assert.equal(res instanceof Error, res.errors.length > 0)
354386

355-
if (fsFilesContents.size > 0) {
387+
if (fsFilesContents.size > 0 || fsFilesDirs.size > 0) {
356388
// re-run as we detected that tests depend on fsReadFileSync contents
357389
config.define.EXODUS_TEST_FSFILES_CONTENTS = stringify([...fsFilesContents.entries()])
390+
config.define.EXODUS_TEST_FSDIRS = stringify([...fsFilesDirs.entries()])
358391
res = await buildWrap(config)
359392
assert.equal(res instanceof Error, res.errors.length > 0)
360393
}

bundler/modules/fs.cjs

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -75,38 +75,49 @@ const stubs = Object.fromEntries(mainKeys.map((key) => [key, () => err(key)]))
7575
const stubsPromises = Object.fromEntries(promisesKeys.map((key) => [key, async () => err(key)]))
7676
const promises = { ...stubsPromises, constants }
7777

78-
// eslint-disable-next-line no-undef
79-
const fsFiles = typeof EXODUS_TEST_FSFILES === 'undefined' ? null : new Set(EXODUS_TEST_FSFILES)
80-
const existsSync = (file) => {
81-
if (fsFiles?.has(file) || fsFilesContents?.has(file)) return true
82-
err('existsSync', file)
78+
const decode = (source, sourceEncoding, encoding) => {
79+
if (encoding && sourceEncoding === encoding) return source
80+
const data = Buffer.from(source, sourceEncoding)
81+
return encoding === undefined ? data : data.toString(encoding)
82+
}
83+
84+
const getOptions = (arg, options) => {
85+
if (typeof arg !== 'string') throw new Error('first argument should be string')
86+
const file = resolve(process.cwd(), arg)
87+
if (typeof options === 'string') return { file, encoding: options, rest: {} }
88+
if (options === undefined) return { file, rest: {} }
89+
if (typeof options !== 'object') throw new Error('Unexpected options')
90+
const { encoding: enc, ...rest } = options
91+
if (enc !== undefined && typeof enc !== 'string') throw new Error('encoding should be a string')
92+
return { file, encoding: enc, rest }
8393
}
8494

8595
const fsFilesContents =
8696
// eslint-disable-next-line no-undef
8797
typeof EXODUS_TEST_FSFILES_CONTENTS === 'undefined' ? null : new Map(EXODUS_TEST_FSFILES_CONTENTS)
88-
const readFileSync = (file, options) => {
89-
let encoding
90-
if (typeof options === 'string') {
91-
encoding = options
92-
} else if (options !== undefined) {
93-
if (typeof options !== 'object') throw new Error('Unexpected readFileSync options')
94-
const { encoding: enc, ...rest } = options
95-
if (enc !== undefined && typeof enc !== 'string') throw new Error('encoding should be a string')
96-
encoding = enc
97-
if (Object.keys(rest).length > 0) throw new Error('Unsupported readFileSync options')
98-
}
98+
const readFileSync = (arg, options) => {
99+
const { file, encoding, rest } = getOptions(arg, options)
100+
if (Object.keys(rest).length > 0) throw new Error('Unsupported readFileSync options')
101+
if (fsFilesContents?.has(file)) return decode(fsFilesContents.get(file), 'base64', encoding)
102+
err('readFileSync', file)
103+
}
99104

100-
if (typeof file !== 'string') throw new Error('file argument should be string')
101-
file = resolve(process.cwd(), file)
102-
if (fsFilesContents?.has(file)) {
103-
const data = Buffer.from(fsFilesContents.get(file), 'base64')
104-
if (encoding?.toLowerCase().replace('-', '') === 'utf8') return data.toString('utf8')
105-
if (encoding === undefined) return data
106-
throw new Error('Unsupported encoding')
107-
}
105+
// eslint-disable-next-line no-undef
106+
const fsDir = typeof EXODUS_TEST_FSDIRS === 'undefined' ? null : new Map(EXODUS_TEST_FSDIRS)
107+
const readdirSync = (arg, options) => {
108+
const { file: dir, encoding, rest } = getOptions(arg, options)
109+
if (Object.keys(rest).length > 0) throw new Error('Unsupported readdirSync options')
110+
const enc = encoding === 'buffer' ? undefined : encoding || 'utf8'
111+
if (fsDir?.has(dir)) return fsDir.get(dir).map((name) => decode(name, 'utf8', enc))
112+
err('readdirSync', dir)
113+
}
108114

109-
err('readFileSync', file)
115+
// eslint-disable-next-line no-undef
116+
const fsFiles = typeof EXODUS_TEST_FSFILES === 'undefined' ? null : new Set(EXODUS_TEST_FSFILES)
117+
const existsSync = (file) => {
118+
if (fsFiles?.has(file) || fsFilesContents?.has(file) || fsDir?.has(file)) return true
119+
err('existsSync', file)
110120
}
111121

112-
module.exports = { ...stubs, existsSync, readFileSync, promises, constants, F_OK, R_OK, W_OK, X_OK }
122+
const implemented = { existsSync, readFileSync, readdirSync }
123+
module.exports = { ...stubs, ...implemented, promises, constants, F_OK, R_OK, W_OK, X_OK }

0 commit comments

Comments
 (0)