@rstackjs/test-utils 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Rspack Contrib
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # @rstackjs/test-utils
2
+
3
+ <p>
4
+ <a href="https://npmjs.com/package/@rstackjs/test-utils">
5
+ <img src="https://img.shields.io/npm/v/@rstackjs/test-utils?style=flat-square&colorA=564341&colorB=EDED91" alt="npm version" />
6
+ </a>
7
+ <img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square&colorA=564341&colorB=EDED91" alt="license" />
8
+ <a href="https://npmcharts.com/compare/@rstackjs/test-utils?minimal=true"><img src="https://img.shields.io/npm/dm/@rstackjs/test-utils.svg?style=flat-square&colorA=564341&colorB=EDED91" alt="downloads" /></a>
9
+ </p>
10
+
11
+ Shared testing utilities for the Rstack ecosystem.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ # pnpm
17
+ pnpm add @rstackjs/test-utils -D
18
+ # yarn
19
+ yarn add @rstackjs/test-utils -D
20
+ # npm
21
+ npm add @rstackjs/test-utils -D
22
+ # bun
23
+ bun add @rstackjs/test-utils -D
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### findFile
29
+
30
+ Finds the first matching path in a file-content map. String matchers use suffix matching, and content hashes are ignored by default.
31
+
32
+ ```ts
33
+ import { findFile } from '@rstackjs/test-utils';
34
+
35
+ const files = {
36
+ '/dist/index.abcdef12.js': 'console.log("hello")',
37
+ '/dist/styles.css': '.root {}',
38
+ };
39
+
40
+ const scriptPath = findFile(files, 'index.js');
41
+ // /dist/index.abcdef12.js
42
+ ```
43
+
44
+ The matcher can be a string, regular expression, or function. Set `ignoreHash` to `false` to match the original path.
45
+
46
+ ```ts
47
+ findFile(files, /styles\.css$/);
48
+ findFile(files, (file) => file.endsWith('.css'));
49
+ findFile(files, 'index.abcdef12.js', { ignoreHash: false });
50
+ ```
51
+
52
+ ### getDistFiles
53
+
54
+ Recursively reads UTF-8 files from a dist directory. Source map files are excluded by default.
55
+
56
+ ```ts
57
+ import { getDistFiles } from '@rstackjs/test-utils';
58
+
59
+ const files = await getDistFiles('./dist');
60
+ const filesWithSourceMaps = await getDistFiles('./dist', true);
61
+ ```
62
+
63
+ ### getFileContent
64
+
65
+ Returns the content of the first file matched by the same matcher and options supported by `findFile`.
66
+
67
+ ```ts
68
+ import { getFileContent } from '@rstackjs/test-utils';
69
+
70
+ const content = getFileContent(files, 'index.js');
71
+ // console.log("hello")
72
+ ```
73
+
74
+ ### getRandomPort
75
+
76
+ Returns an available TCP port that has not already been returned by the current process.
77
+
78
+ ```ts
79
+ import { getRandomPort } from '@rstackjs/test-utils';
80
+
81
+ const port = await getRandomPort();
82
+ ```
83
+
84
+ You can optionally provide the starting port. If it is unavailable, the next available port is returned.
85
+
86
+ ```ts
87
+ const port = await getRandomPort(30000);
88
+ ```
89
+
90
+ ### isPortAvailable
91
+
92
+ Checks whether a TCP port is currently available to bind. The port is released before the promise resolves.
93
+
94
+ ```ts
95
+ import { isPortAvailable } from '@rstackjs/test-utils';
96
+
97
+ const available = await isPortAvailable(30000);
98
+ ```
99
+
100
+ ### proxyConsole
101
+
102
+ Captures console output and removes ANSI control characters. By default, it captures `log`, `warn`, `info`, and `error`.
103
+
104
+ ```ts
105
+ import { proxyConsole } from '@rstackjs/test-utils';
106
+
107
+ const logHelper = proxyConsole({ types: ['warn', 'error'] });
108
+
109
+ try {
110
+ console.warn('Something happened');
111
+ console.error('Something failed');
112
+
113
+ await logHelper.expectLog('Something happened');
114
+ logHelper.expectNoLog('Unexpected error');
115
+ } finally {
116
+ logHelper.restore();
117
+ }
118
+
119
+ console.log(logHelper.logs);
120
+ ```
121
+
122
+ ### readDirContents
123
+
124
+ Recursively reads UTF-8 files from a directory and returns a file-content map keyed by sorted absolute paths.
125
+
126
+ ```ts
127
+ import { readDirContents } from '@rstackjs/test-utils';
128
+
129
+ const files = await readDirContents('./dist');
130
+
131
+ for (const [filePath, content] of Object.entries(files)) {
132
+ console.log(filePath, content);
133
+ }
134
+ ```
135
+
136
+ ### toPosixPath
137
+
138
+ Converts backslash path separators to POSIX forward slashes.
139
+
140
+ ```ts
141
+ import { toPosixPath } from '@rstackjs/test-utils';
142
+
143
+ const normalizedPath = toPosixPath('C:\\project\\src\\index.ts');
144
+ // C:/project/src/index.ts
145
+ ```
146
+
147
+ ### waitFor
148
+
149
+ Waits for a duration or polls a synchronous or asynchronous condition until it returns `true`.
150
+
151
+ ```ts
152
+ import { waitFor } from '@rstackjs/test-utils';
153
+
154
+ await waitFor(() => server.isReady(), {
155
+ interval: 50,
156
+ timeout: 5000,
157
+ });
158
+ ```
159
+
160
+ Condition polling uses a 100ms interval and a 5-second timeout by default.
161
+
162
+ ## License
163
+
164
+ [MIT](./LICENSE).
@@ -0,0 +1,12 @@
1
+ export type FileMatcher = string | RegExp | ((file: string) => boolean);
2
+ export type FindFileOptions = {
3
+ /**
4
+ * Whether to ignore a content hash in the filename.
5
+ * @default true
6
+ */
7
+ ignoreHash?: boolean;
8
+ };
9
+ /**
10
+ * Find the first file path that matches the provided matcher.
11
+ */
12
+ export declare const findFile: (files: Record<string, string>, matcher: FileMatcher, options?: FindFileOptions) => string;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Recursively read UTF-8 files from a dist directory.
3
+ *
4
+ * Source map files are excluded by default.
5
+ */
6
+ export declare const getDistFiles: (distPath: string, sourceMaps?: boolean) => Promise<Record<string, string>>;
@@ -0,0 +1,5 @@
1
+ import { type FileMatcher, type FindFileOptions } from './findFile.js';
2
+ /**
3
+ * Return the content of the first file that matches the provided matcher.
4
+ */
5
+ export declare const getFileContent: (files: Record<string, string>, matcher: FileMatcher, options?: FindFileOptions) => string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Check whether a TCP port is available to bind.
3
+ *
4
+ * The port is released before the returned promise resolves.
5
+ */
6
+ export declare const isPortAvailable: (port: number) => Promise<boolean>;
7
+ /**
8
+ * Return an available TCP port that has not been returned by this process.
9
+ *
10
+ * The default starting port is randomly selected between 15000 and 45000.
11
+ */
12
+ export declare const getRandomPort: (startPort?: number) => Promise<number>;
@@ -0,0 +1,8 @@
1
+ export { type FileMatcher, findFile, type FindFileOptions, } from './findFile.js';
2
+ export { getDistFiles } from './getDistFiles.js';
3
+ export { getFileContent } from './getFileContent.js';
4
+ export { getRandomPort, isPortAvailable } from './getRandomPort.js';
5
+ export { type ConsoleType, createLogHelper, type ExtendedLogHelper, type LogHelper, proxyConsole, type ProxyConsoleOptions, } from './proxyConsole.js';
6
+ export { readDirContents } from './readDirContents.js';
7
+ export { toPosixPath } from './toPosixPath.js';
8
+ export { waitFor, type WaitForCondition, type WaitForOptions, } from './waitFor.js';
package/dist/index.js ADDED
@@ -0,0 +1,194 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join, resolve as external_node_path_resolve } from "node:path";
3
+ import node_net from "node:net";
4
+ import { stripVTControlCharacters, styleText } from "node:util";
5
+ const HASH_PATTERN = /\.[0-9a-z]{8,}(?=\.)/gi;
6
+ const toMatcher = (matcher)=>{
7
+ if ('function' == typeof matcher) return matcher;
8
+ if ('string' == typeof matcher) return (file)=>file.endsWith(matcher);
9
+ const regexp = new RegExp(matcher.source, matcher.flags);
10
+ return (file)=>{
11
+ regexp.lastIndex = 0;
12
+ return regexp.test(file);
13
+ };
14
+ };
15
+ const findFile = (files, matcher, options = {})=>{
16
+ const { ignoreHash = true } = options;
17
+ const matcherFn = toMatcher(matcher);
18
+ for (const file of Object.keys(files)){
19
+ const comparableFile = ignoreHash ? file.replace(HASH_PATTERN, '') : file;
20
+ if (matcherFn(comparableFile)) return file;
21
+ }
22
+ throw new Error(`Unable to find file matching "${matcher.toString()}"`);
23
+ };
24
+ const collectFilePaths = async (directoryPath)=>{
25
+ const entries = await readdir(directoryPath, {
26
+ withFileTypes: true
27
+ });
28
+ const filePaths = await Promise.all(entries.map((entry)=>{
29
+ const filePath = join(directoryPath, entry.name);
30
+ if (entry.isDirectory()) return collectFilePaths(filePath);
31
+ return entry.isFile() ? [
32
+ filePath
33
+ ] : [];
34
+ }));
35
+ return filePaths.flat();
36
+ };
37
+ const readDirContents = async (directoryPath)=>{
38
+ const filePaths = await collectFilePaths(external_node_path_resolve(directoryPath));
39
+ filePaths.sort();
40
+ const entries = await Promise.all(filePaths.map(async (filePath)=>[
41
+ filePath,
42
+ await readFile(filePath, 'utf-8')
43
+ ]));
44
+ return Object.fromEntries(entries);
45
+ };
46
+ const getDistFiles = async (distPath, sourceMaps = false)=>{
47
+ const files = await readDirContents(distPath);
48
+ if (sourceMaps) return files;
49
+ return Object.fromEntries(Object.entries(files).filter(([filePath])=>!filePath.endsWith('.map')));
50
+ };
51
+ const getFileContent = (files, matcher, options)=>files[findFile(files, matcher, options)];
52
+ const usedPorts = new Set();
53
+ const isPortAvailable = (port)=>{
54
+ try {
55
+ const server = node_net.createServer().listen(port);
56
+ return new Promise((resolve)=>{
57
+ server.on('listening', ()=>{
58
+ server.close(()=>{
59
+ resolve(true);
60
+ });
61
+ });
62
+ server.on('error', ()=>{
63
+ resolve(false);
64
+ });
65
+ });
66
+ } catch {
67
+ return Promise.resolve(false);
68
+ }
69
+ };
70
+ const getRandomPort = async (startPort = Math.ceil(30000 * Math.random()) + 15000)=>{
71
+ let port = startPort;
72
+ while(port <= 65535){
73
+ if (!usedPorts.has(port) && await isPortAvailable(port)) {
74
+ usedPorts.add(port);
75
+ return port;
76
+ }
77
+ port++;
78
+ }
79
+ throw new Error('No available ports found in the valid range.');
80
+ };
81
+ const toPosixPath = (filePath)=>filePath.replaceAll('\\', '/');
82
+ const matchPattern = (log, pattern, options = {})=>{
83
+ const logToCheck = options.posix ? toPosixPath(log) : log;
84
+ if ('string' == typeof pattern) return options.strict ? logToCheck.split('\n').some((line)=>line.trim() === pattern.trim()) : logToCheck.includes(pattern);
85
+ if (pattern instanceof RegExp) return pattern.test(logToCheck);
86
+ return pattern(logToCheck);
87
+ };
88
+ const createLogHelper = ()=>{
89
+ const logs = [];
90
+ const originalLogs = [];
91
+ const logPatterns = new Set();
92
+ const clearLogs = ()=>{
93
+ logs.splice(0);
94
+ };
95
+ const addLog = (input)=>{
96
+ const log = stripVTControlCharacters(input);
97
+ logs.push(log);
98
+ originalLogs.push(input);
99
+ for (const { pattern, resolve, options } of logPatterns)if (matchPattern(log, pattern, options)) resolve(true);
100
+ };
101
+ const expectLog = async (pattern, options = {})=>{
102
+ if (logs.some((log)=>matchPattern(log, pattern, options))) return true;
103
+ return new Promise((resolve, reject)=>{
104
+ const timeoutId = setTimeout(()=>{
105
+ const title = styleText([
106
+ 'bold',
107
+ 'red'
108
+ ], 'Timeout: Expected log not found within 5 seconds.');
109
+ const expected = styleText('yellow', pattern.toString());
110
+ reject(new Error(`${title}\nExpect: ${expected}\nGet:\n${originalLogs.join('\n')}`));
111
+ }, 5000);
112
+ const patternEntry = {
113
+ pattern,
114
+ options,
115
+ resolve: (value)=>{
116
+ clearTimeout(timeoutId);
117
+ logPatterns.delete(patternEntry);
118
+ resolve(value);
119
+ }
120
+ };
121
+ logPatterns.add(patternEntry);
122
+ });
123
+ };
124
+ const expectNoLog = (pattern, options = {})=>{
125
+ const result = logs.some((log)=>matchPattern(log, pattern, options));
126
+ if (result) {
127
+ const title = styleText([
128
+ 'bold',
129
+ 'red'
130
+ ], 'Unexpected log found.');
131
+ const unexpected = styleText('yellow', pattern.toString());
132
+ throw new Error(`${title}\nUnexpected: ${unexpected}\nGet:\n${originalLogs.join('\n')}`);
133
+ }
134
+ };
135
+ return {
136
+ logs,
137
+ originalLogs,
138
+ addLog,
139
+ clearLogs,
140
+ expectLog,
141
+ expectNoLog
142
+ };
143
+ };
144
+ const proxyConsole = ({ types = [
145
+ 'log',
146
+ 'warn',
147
+ 'info',
148
+ 'error'
149
+ ] } = {})=>{
150
+ const restores = [];
151
+ const logHelper = createLogHelper();
152
+ for (const type of Array.isArray(types) ? types : [
153
+ types
154
+ ]){
155
+ const method = console[type];
156
+ restores.push(()=>{
157
+ console[type] = method;
158
+ });
159
+ console[type] = (...args)=>{
160
+ const logMessage = args.map((arg)=>{
161
+ if ('string' == typeof arg) return arg;
162
+ return 'object' == typeof arg ? JSON.stringify(arg) : String(arg);
163
+ }).join(' ');
164
+ logHelper.addLog(logMessage);
165
+ };
166
+ }
167
+ const restore = ()=>{
168
+ for (const restoreMethod of restores)restoreMethod();
169
+ };
170
+ const printCapturedLogs = ()=>{
171
+ restore();
172
+ console.log(logHelper.originalLogs.join('\n'));
173
+ };
174
+ return {
175
+ restore,
176
+ printCapturedLogs,
177
+ ...logHelper
178
+ };
179
+ };
180
+ const delay = (milliseconds)=>new Promise((resolve)=>{
181
+ setTimeout(resolve, milliseconds);
182
+ });
183
+ async function waitFor(millisecondsOrCondition, options = {}) {
184
+ if ('number' == typeof millisecondsOrCondition) return delay(millisecondsOrCondition);
185
+ const { interval = 100, timeout = 5000 } = options;
186
+ const startTime = Date.now();
187
+ while(true){
188
+ if (await millisecondsOrCondition()) return;
189
+ const elapsedTime = Date.now() - startTime;
190
+ if (elapsedTime >= timeout) throw new Error(`Timed out after ${timeout}ms while waiting for condition.`);
191
+ await delay(Math.min(interval, timeout - elapsedTime));
192
+ }
193
+ }
194
+ export { createLogHelper, findFile, getDistFiles, getFileContent, getRandomPort, isPortAvailable, proxyConsole, readDirContents, toPosixPath, waitFor };
@@ -0,0 +1,35 @@
1
+ export type ConsoleType = 'error' | 'group' | 'info' | 'log' | 'table' | 'warn';
2
+ type LogPattern = string | RegExp | ((log: string) => boolean);
3
+ type MatchPatternOptions = {
4
+ /**
5
+ * Whether to use exact line matching instead of substring matching.
6
+ * @default false
7
+ */
8
+ strict?: boolean;
9
+ /**
10
+ * Whether to convert file paths to POSIX format before matching.
11
+ * @default false
12
+ */
13
+ posix?: boolean;
14
+ };
15
+ export declare const createLogHelper: () => {
16
+ logs: string[];
17
+ originalLogs: string[];
18
+ addLog: (input: string) => void;
19
+ clearLogs: () => void;
20
+ expectLog: (pattern: LogPattern, options?: MatchPatternOptions) => Promise<boolean>;
21
+ expectNoLog: (pattern: LogPattern, options?: MatchPatternOptions) => void;
22
+ };
23
+ export type LogHelper = ReturnType<typeof createLogHelper>;
24
+ export type ProxyConsoleOptions = {
25
+ types?: ConsoleType | ConsoleType[];
26
+ };
27
+ export type ExtendedLogHelper = LogHelper & {
28
+ /** Restore the original console methods. */
29
+ restore: () => void;
30
+ /** Restore the original console methods and print the captured logs. */
31
+ printCapturedLogs: () => void;
32
+ };
33
+ /** Proxy the console methods to capture logs. */
34
+ export declare const proxyConsole: ({ types, }?: ProxyConsoleOptions) => ExtendedLogHelper;
35
+ export {};
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Recursively read UTF-8 files from a directory.
3
+ *
4
+ * The returned record uses sorted absolute file paths as keys.
5
+ */
6
+ export declare const readDirContents: (directoryPath: string) => Promise<Record<string, string>>;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Convert backslash path separators to POSIX forward slashes.
3
+ */
4
+ export declare const toPosixPath: (filePath: string) => string;
@@ -0,0 +1,15 @@
1
+ export type WaitForCondition = () => boolean | Promise<boolean>;
2
+ export type WaitForOptions = {
3
+ /**
4
+ * Delay between condition checks in milliseconds.
5
+ * @default 100
6
+ */
7
+ interval?: number;
8
+ /**
9
+ * Maximum time to wait for the condition in milliseconds.
10
+ * @default 5000
11
+ */
12
+ timeout?: number;
13
+ };
14
+ export declare function waitFor(milliseconds: number): Promise<void>;
15
+ export declare function waitFor(condition: WaitForCondition, options?: WaitForOptions): Promise<void>;
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@rstackjs/test-utils",
3
+ "version": "0.0.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/rstackjs/test-utils"
7
+ },
8
+ "license": "MIT",
9
+ "type": "module",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "types": "./dist/index.d.ts",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "devDependencies": {
21
+ "@types/node": "^24.13.2",
22
+ "prettier": "^3.9.4",
23
+ "rstack": "0.0.2",
24
+ "typescript": "^7.0.2"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "registry": "https://registry.npmjs.org/"
29
+ },
30
+ "scripts": {
31
+ "build": "rs lib",
32
+ "dev": "rs lib -w",
33
+ "lint": "rs lint && prettier -c .",
34
+ "lint:write": "rs lint --fix && prettier -w .",
35
+ "test": "rs test",
36
+ "bump": "pnpx bumpp"
37
+ }
38
+ }