diff --git a/lib/internal/shims/process.js b/lib/internal/shims/process.js new file mode 100644 index 000000000..48ae9b2cf --- /dev/null +++ b/lib/internal/shims/process.js @@ -0,0 +1,27 @@ +/* wraps the internal process module, circumventing issues with some polyfills (see #539) */ + +/** @type {import('node:process')} */ +const process = ((base, esmKey, keys, isValid) => { + // check if top-level es module, in which case it may have a default export + if (esmKey in base && base[esmKey] === true) { + let candidate + for (const key of keys) { + if (!(key in base)) { + continue + } + candidate = base[key] + // sanity check + if (isValid(candidate)) { + return candidate + } + } + } + return base +})( + require('process/'), + '__esModule', + ['default', 'process'], + (candidate) => 'nextTick' in candidate +) + +module.exports = process diff --git a/lib/internal/streams/destroy.js b/lib/internal/streams/destroy.js index 38292315e..fe9651841 100644 --- a/lib/internal/streams/destroy.js +++ b/lib/internal/streams/destroy.js @@ -2,7 +2,7 @@ /* replacement start */ -const process = require('process/') +const process = require('../shims/process') /* replacement end */ diff --git a/lib/internal/streams/duplexify.js b/lib/internal/streams/duplexify.js index 05740d70f..bb1e251a0 100644 --- a/lib/internal/streams/duplexify.js +++ b/lib/internal/streams/duplexify.js @@ -1,6 +1,6 @@ /* replacement start */ -const process = require('process/') +const process = require('../shims/process') /* replacement end */ diff --git a/lib/internal/streams/end-of-stream.js b/lib/internal/streams/end-of-stream.js index 29feb936b..b713c2ef1 100644 --- a/lib/internal/streams/end-of-stream.js +++ b/lib/internal/streams/end-of-stream.js @@ -1,6 +1,6 @@ /* replacement start */ -const process = require('process/') +const process = require('../shims/process') /* replacement end */ // Ported from https://github.com/mafintosh/end-of-stream with diff --git a/lib/internal/streams/from.js b/lib/internal/streams/from.js index c7e753140..5cd814260 100644 --- a/lib/internal/streams/from.js +++ b/lib/internal/streams/from.js @@ -2,7 +2,7 @@ /* replacement start */ -const process = require('process/') +const process = require('../shims/process') /* replacement end */ diff --git a/lib/internal/streams/pipeline.js b/lib/internal/streams/pipeline.js index a2bab8800..cafe9aad3 100644 --- a/lib/internal/streams/pipeline.js +++ b/lib/internal/streams/pipeline.js @@ -1,6 +1,6 @@ /* replacement start */ -const process = require('process/') +const process = require('../shims/process') /* replacement end */ // Ported from https://github.com/mafintosh/pump with diff --git a/lib/internal/streams/readable.js b/lib/internal/streams/readable.js index 5b494ba3d..b4e391a12 100644 --- a/lib/internal/streams/readable.js +++ b/lib/internal/streams/readable.js @@ -1,6 +1,6 @@ /* replacement start */ -const process = require('process/') +const process = require('../shims/process') /* replacement end */ // Copyright Joyent, Inc. and other Node contributors. diff --git a/lib/internal/streams/writable.js b/lib/internal/streams/writable.js index 8a2800346..1a730b40e 100644 --- a/lib/internal/streams/writable.js +++ b/lib/internal/streams/writable.js @@ -1,6 +1,6 @@ /* replacement start */ -const process = require('process/') +const process = require('../shims/process') /* replacement end */ // Copyright Joyent, Inc. and other Node contributors. diff --git a/package.json b/package.json index c3d6f8a40..9d16dadbf 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:prepare": "node test/browser/runner-prepare.mjs", "test:browsers": "node test/browser/runner-browser.mjs", "test:bundlers": "node test/browser/runner-node.mjs", + "test:vite": "node test/browser/runner-vite.mjs", "test:readable-stream-only": "node readable-stream-test/runner-prepare.mjs", "coverage": "c8 -c ./c8.json tap --rcfile=./tap.yml test/parallel/test-*.js test/ours/test-*.js", "format": "prettier -w src lib test", @@ -77,6 +78,9 @@ "tape": "^5.5.3", "tar": "^6.1.11", "undici": "^5.1.1", + "vite": "^5.4.9", + "vite-plugin-commonjs": "^0.10.3", + "vite-plugin-node-polyfills": "^0.22.0", "webpack": "^5.72.1", "webpack-cli": "^4.9.2" }, diff --git a/test/browser/fixtures/vite/index.html b/test/browser/fixtures/vite/index.html new file mode 100644 index 000000000..2cb9b0b1d --- /dev/null +++ b/test/browser/fixtures/vite/index.html @@ -0,0 +1,10 @@ + + + + readable-stream test:vite + + +

Testing...

+ + + diff --git a/test/browser/fixtures/vite/index.mjs b/test/browser/fixtures/vite/index.mjs new file mode 100644 index 000000000..328a6945a --- /dev/null +++ b/test/browser/fixtures/vite/index.mjs @@ -0,0 +1,56 @@ +import { reportSuccess, reportError, reportLog } from './lib/reporting.mjs' +import { createBankStream } from './lib/bank.mjs' +import { createCollectorStream } from './lib/collector.mjs' + +const DATA_SIZE = 512 + +async function test() { + await reportLog('Generating test data...') + + const expected = new Uint8Array(DATA_SIZE) + window.crypto.getRandomValues(expected) + + await reportLog('Creating input stream...') + const readable = createBankStream(expected) + + await reportLog('Creating output stream...') + const writable = createCollectorStream() + + await reportLog('Piping...') + await new Promise((resolve, reject) => { + readable.pipe(writable) + .on('finish', resolve) + .on('error', reject) + }) + + await reportLog('Comparing...') + const retrieved = writable.collect() + if (retrieved.length !== DATA_SIZE) { + throw new Error(`Expected output data of length ${DATA_SIZE}, got ${retrieved.length}`) + } + + let nMatch = 0 + let firstNonMatch = -1 + for (let i = 0; i < DATA_SIZE; i++) { + if (expected[i] === retrieved[i]) { + nMatch++ + } else if (firstNonMatch === -1) { + firstNonMatch = i + } + } + + if (firstNonMatch === -1) { + await reportLog('100% match!') + } else { + await reportLog(`expected: ${expected}`) + await reportLog(`actual: ${retrieved}`) + + const percent = (nMatch / DATA_SIZE) * 100 + throw new Error(`${percent.toFixed(2)}% match (first mismatch at position ${firstNonMatch})`) + } +} + +(async () => { + await test() + await reportSuccess() +})().catch(reportError) diff --git a/test/browser/fixtures/vite/lib/bank.mjs b/test/browser/fixtures/vite/lib/bank.mjs new file mode 100644 index 000000000..539004caf --- /dev/null +++ b/test/browser/fixtures/vite/lib/bank.mjs @@ -0,0 +1,24 @@ +import { Readable } from '@me' + +/** + * Returns a Readable stream that reports the content of a buffer + * @param buffer {Uint8Array} + * @return {import('stream').Readable} + */ +export function createBankStream(buffer) { + let bytesRead = 0 + const readable = new Readable() + readable._read = function (count) { + let end = bytesRead + count + let done = false + if (end >= buffer.byteLength) { + end = buffer.byteLength + done = true + } + readable.push(buffer.subarray(bytesRead, bytesRead = end)) + if (done) { + readable.push(null) + } + } + return readable +} diff --git a/test/browser/fixtures/vite/lib/collector.mjs b/test/browser/fixtures/vite/lib/collector.mjs new file mode 100644 index 000000000..dbd3e60e7 --- /dev/null +++ b/test/browser/fixtures/vite/lib/collector.mjs @@ -0,0 +1,51 @@ +import { Writable } from '@me' + +const LOAD_FACTOR = 0.75 +const INITIAL_CAPACITY = 16 + +/** + * Returns a Writable stream that stores chunks to an internal buffer, retrievable with #collect(). + * @returns {import("stream").Writable & { collect(): Uint8Array }} + */ +export function createCollectorStream() { + let capacity = INITIAL_CAPACITY + let buffer = new Uint8Array(capacity) + let size = 0 + + // ensures that "buffer" can hold n additional bytes + function provision(n) { + const requiredSize = size + n + if (requiredSize <= capacity) { + return + } + const newCapacity = Math.ceil((requiredSize + 1) / LOAD_FACTOR) + let arrayBuffer = buffer.buffer + if ('transfer' in arrayBuffer) { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/transfer + arrayBuffer = arrayBuffer.transfer(newCapacity) + buffer = new Uint8Array(arrayBuffer) + } else { + const copy = new Uint8Array(newCapacity) + copy.set(buffer, 0) + buffer = copy + } + capacity = newCapacity + } + + const writable = new Writable() + writable._write = function (chunk, _, cb) { + if (!(chunk instanceof Uint8Array)) { + throw new Error('Unexpected chunk') + } + provision(chunk.byteLength) + buffer.set(chunk, size) + size += chunk.byteLength + cb(null) + } + + return Object.assign(writable, { + collect: function () { + return buffer.subarray(0, size) + } + }) +} diff --git a/test/browser/fixtures/vite/lib/reporting.mjs b/test/browser/fixtures/vite/lib/reporting.mjs new file mode 100644 index 000000000..bcbdd5ff6 --- /dev/null +++ b/test/browser/fixtures/vite/lib/reporting.mjs @@ -0,0 +1,52 @@ +const ENDPOINT_STATUS = '/status' +const ENDPOINT_LOG = '/log' + +function closeCurrentWindow() { + window.close() +} + +async function reportRaw(endpoint, data, tryBeacon) { + data = JSON.stringify(data) + if (tryBeacon && typeof navigator === 'object' && 'sendBeacon' in navigator) { + data = (new TextEncoder()).encode(data) + data = data.buffer + navigator.sendBeacon(endpoint, data) + } else { + await fetch(endpoint, { + method: 'POST', + body: data, + headers: { + 'Content-Type': 'application/json' + } + }) + } +} + +export async function reportSuccess() { + await reportRaw(ENDPOINT_STATUS, { success: true }, true) + closeCurrentWindow() +} + +export async function reportError(error) { + let msg = 'Unknown error' + if (typeof error === 'string') { + msg = error + } else if (typeof error === 'object') { + if (error instanceof Error) { + msg = error.message + } else if (error !== null) { + msg = `${error}` + } + } else { + msg = `${error}` + } + await reportRaw(ENDPOINT_STATUS, { success: false, error: msg }, true) + closeCurrentWindow() +} + +/** + * @param message {string} + */ +export async function reportLog(message) { + await reportRaw(ENDPOINT_LOG, { message }, false) +} diff --git a/test/browser/fixtures/vite/vite.config.mjs b/test/browser/fixtures/vite/vite.config.mjs new file mode 100644 index 000000000..41b3f44e0 --- /dev/null +++ b/test/browser/fixtures/vite/vite.config.mjs @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import { nodePolyfills } from 'vite-plugin-node-polyfills' +import commonjs from 'vite-plugin-commonjs' + +// noinspection JSUnusedGlobalSymbols +export default defineConfig({ + mode: 'development', + build: { + sourcemap: true + }, + plugins: [ + commonjs(), + nodePolyfills() + ] +}) diff --git a/test/browser/runner-vite.mjs b/test/browser/runner-vite.mjs new file mode 100644 index 000000000..acfa1607e --- /dev/null +++ b/test/browser/runner-vite.mjs @@ -0,0 +1,328 @@ +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer } from 'vite' +import * as stream from 'node:stream' +import * as fs from 'node:fs/promises' + +const TIMEOUT_MS = 10 * 1e3 + +// Enum for success state +/** @type {{ [p: number]: string, IDLE: 0, SUCCESS: 1, ERROR: 2 }} */ +const State = (() => { + const _s = {} + _s[_s[0] = 'IDLE'] = 0 + _s[_s[1] = 'SUCCESS'] = 1 + _s[_s[2] = 'ERROR'] = 2 + return _s +})() + +// Utility to read a http IncomingMessage into a buffer +/** + * @param msg {import("http").IncomingMessage} + * @return {Promise} + */ +async function readMessage(msg) { + const chunks = [] + const writable = new stream.Writable({ + write: function(chunk, encoding, next) { + if (chunk === null) { + return + } + if (!(chunk instanceof Uint8Array)) { + throw new Error('Chunk is not Uint8Array') + } + chunks.push(chunk) + next() + } + }) + const promise = new Promise((resolve, reject) => { + writable.on('finish', resolve) + writable.on('error', reject) + }) + msg.pipe(writable) + await promise + return Buffer.concat(chunks) +} + +// Create a middleware that registers an endpoint on /status, so that the runner can receive the status. +// Also registers /log to log debug messages. +function createMiddleware() { + const stateChangeCallbacks = [] + let error = null + + /** + * @param state {0 | 1 | 2} + */ + function setState(state) { + for (const cb of stateChangeCallbacks.splice(0)) { + cb(state) + } + } + + /** + * @param timeout {number} + * @return {Promise<0 | 1 | 2>} + */ + function awaitStateChange(timeout) { + let timeoutHandle = -1 + return Promise.race([ + new Promise((resolve, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error('Timed out (' + timeout + 'ms)')) + }, timeout) + }), + new Promise((resolve) => { + clearTimeout(timeoutHandle) + stateChangeCallbacks.push(resolve) + }) + ]) + } + + /** + * @return {Error | null} + */ + function getError() { + return error + } + + /** + * @param req {import("http").IncomingMessage} + */ + async function handleAsync(req) { + let action = -1 + switch (req.url) { + case '/status': + action = 0 + break + case '/log': + action = 1 + break + default: + return + } + if (action === -1 || req.method !== 'POST') { + return + } + + // Read the JSON body + /** @type { { success?: boolean, error?: string, message?: string } } */ + const body = JSON.parse((new TextDecoder()).decode(await readMessage(req))) + + if (action) { + // log + if (!('message' in body)) { + throw new Error('Log data missing required property: message') + } + console.log(`[browser] ${body.message}`) + } else { + // status + if (!('success' in body)) { + throw new Error('Status data missing required property: success') + } + if (body.success) { + setState(State.SUCCESS) + } else { + if ('error' in body) { + error = new Error(`${body.error}`) + } + setState(State.ERROR) + } + } + } + + /** + * @param req {import("http").IncomingMessage} + * @param res {import("http").ServerResponse} + * @param next {() => void} + */ + function handle(req, res, next) { + handleAsync(req).then(() => { + next() + }).catch((err) => { + console.error(err) + process.exit(1) + }) + } + + return Object.assign(handle, { + awaitStateChange, + getError + }) +} + +/** + * If the named directory exists, clears out that directory. + * If the path exists as a non-directory, throws an error. + * If the path does not exist, creates a new directory. + * @param dir {string} + * @returns {Promise} + */ +async function ensureEmptyDir(dir) { + let conflict = false + let clear = false + try { + const stats = await fs.stat(dir) + conflict = !stats.isDirectory() + clear = true + } catch (ignored) { } + if (conflict) { + throw new Error(`Creating directory at ${dir} would conflict with existing filesystem entity`) + } + if (clear) { + for (const ent of await fs.readdir(dir, { withFileTypes: true })) { + if (ent.isDirectory()) { + await fs.rm(resolve(dir, ent.name), { recursive: true, force: true }) + } else { + await fs.rm(resolve(dir, ent.name), { force: true }) + } + } + } else { + await fs.mkdir(dir, { recursive: true }) + } +} + +/** + * Copies the content of one directory to another, recursively. The destination directory + * is created with semantics defined by {@link ensureEmptyDir}. + * @param from {string} + * @param to {string} + * @returns {Promise} + */ +async function recursiveCopyDir(from, to) { + await ensureEmptyDir(to) + + for (const ent of await fs.readdir(from, { withFileTypes: true })) { + if (ent.isDirectory()) { + await recursiveCopyDir( + resolve(from, ent.name), + resolve(to, ent.name) + ) + } else if (ent.isFile()) { + await fs.cp( + resolve(from, ent.name), + resolve(to, ent.name) + ) + } + } +} + +/** + * Creates a copy of the project directory within node_modules, with an arbitrary name returned by this method. + * The goal is to force Vite into treating this project as external to the Vite test source. + * TL;DR, makes "@me" work in the Vite test source. + * @param moduleRoot {string} + * @returns {Promise} + */ +async function createMirror(moduleRoot) { + const name = 'vitest-self-hack' + const link = resolve(moduleRoot, `node_modules/${name}`) + + await ensureEmptyDir(link) + + // Symlink everything except package.json & lib + for (const ent of await fs.readdir(moduleRoot, { withFileTypes: true })) { + let type + if (ent.isDirectory()) { + if (ent.name === 'lib') { + continue + } + type = 'dir' + } else if (ent.isFile()) { + if (ent.name === 'package.json') { + continue + } + type = 'file' + } else { + continue + } + await fs.symlink( + resolve(moduleRoot, ent.name), + resolve(link, ent.name), + type + ) + } + + // Copy lib + await recursiveCopyDir( + resolve(moduleRoot, 'lib'), + resolve(link, 'lib') + ) + + // Copy package.json (with our fake name) + const pkgJson = JSON.parse(await fs.readFile(resolve(moduleRoot, 'package.json'), { encoding: 'utf-8' })) + pkgJson.name = name + await fs.writeFile( + resolve(link, 'package.json'), + (new TextEncoder()).encode(JSON.stringify(pkgJson, null, 4)), + { flag: 'w' } + ) + return name +} + +async function main() { + // Execute the test + const __dirname = fileURLToPath(new URL('.', import.meta.url)) + const moduleRoot = resolve(__dirname, '../..') + const viteRoot = resolve(__dirname, './fixtures/vite') + const start = performance.now() + + console.log('Creating module mirror...') + const moduleName = await createMirror(moduleRoot) + + const middleware = createMiddleware() + const server = await createServer({ + configFile: resolve(viteRoot, 'vite.config.mjs'), + root: viteRoot, + plugins: [ + { + name: 'Status Broker', + configureServer: async (server) => { + console.log('Binding status middleware') + server.middlewares.use(middleware) + } + } + ], + server: { + port: 1337 + }, + resolve: { + alias: { + '@me': moduleName + } + } + }) + + await server.listen() + console.log('Vite server started') + server.printUrls() + + try { + /** @type {Promise<0 | 1 | 2>} */ + const statePromise = middleware.awaitStateChange(TIMEOUT_MS) + + console.log('Opening in browser and awaiting test results...') + server.openBrowser() + + const state = await statePromise + const elapsed = performance.now() - start + const elapsedStr = elapsed.toFixed(2) + ' ms' + switch (state) { + case State.SUCCESS: + console.log('Test succeeded in ' + elapsedStr) + break + case State.ERROR: + console.log('Test failed in ' + elapsedStr) + throw middleware.getError() + default: + throw new Error('Assertion failed: state is out of bounds (' + state + ' / ' + State[state] + ')') + } + } finally { + await server.close() + console.log('Vite server stopped') + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +})