diff --git a/.size-limit.json b/.size-limit.json index 84e5efbf88..f5bba23632 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -19,7 +19,7 @@ "README.md", "LICENSE" ], - "limit": "129.46 kB", + "limit": "132 kB", "brotli": false, "gzip": false }, @@ -33,21 +33,21 @@ "build/globals.js", "build/deno.js" ], - "limit": "881.85 kB", + "limit": "885 kB", "brotli": false, "gzip": false }, { "name": "libdefs", "path": "build/*.d.ts", - "limit": "45.68 kB", + "limit": "48 kB", "brotli": false, "gzip": false }, { "name": "vendor", "path": "build/vendor-*.{cjs,d.ts}", - "limit": "835.65 kB", + "limit": "840 kB", "brotli": false, "gzip": false }, @@ -66,7 +66,7 @@ "README.md", "LICENSE" ], - "limit": "944.60 kB", + "limit": "950 kB", "brotli": false, "gzip": false } diff --git a/build/cli.cjs b/build/cli.cjs index e494590738..533b232809 100755 --- a/build/cli.cjs +++ b/build/cli.cjs @@ -63,7 +63,7 @@ var import_util = require("./util.cjs"); function transformMarkdown(buf) { var _a2; const out = []; - const tabRe = /^( +|\t)/; + const tabRe = /^( {4,}|\t)/; const fenceRe = new RegExp("^(? {0,3})(?(`{3,20}|~{3,20}))(?:(?js|javascript|ts|typescript)|(?sh|shell|bash)|.*)$"); let state = "root"; let prevEmpty = true; @@ -103,18 +103,18 @@ function transformMarkdown(buf) { state = "tab"; continue; } - prevEmpty = line === ""; + prevEmpty = line.trim() === ""; out.push("// " + line); continue; } case "tab": - if (line === "") out.push(""); + if (line.trim() === "") out.push(""); else if (tabRe.test(line)) out.push(line); else { out.push("// " + line); state = "root"; } - prevEmpty = line === ""; + prevEmpty = line.trim() === ""; break; case "fence": if (isEnd(line)) { @@ -250,6 +250,7 @@ function runScript(script, scriptPath, tempPath) { import_node_process2.default.once("exit", rmTemp); yield import(import_node_url.default.pathToFileURL(scriptPath).toString()); } finally { + import_node_process2.default.off("exit", rmTemp); rmTemp(); } }); diff --git a/build/core.cjs b/build/core.cjs index cd9f1f0706..17833141cc 100644 --- a/build/core.cjs +++ b/build/core.cjs @@ -238,7 +238,7 @@ ${details}`; static getCallerLocationFromString(stackString = "unknown") { const lines = stackString.split(/^\s*(at\s)?/m).filter((s) => s == null ? void 0 : s.includes(":")); const i = lines.findIndex((l) => l.includes("Proxy.set")); - const offset = i < 0 ? i : i + 2; + const offset = i < 0 ? lines.findIndex((l) => /:\d+/.test(l)) : i + 2; return (lines.find((l) => l.includes("file://")) || lines[offset] || stackString).trim(); } static getCallerLocation(err = new Error("zx error")) { @@ -282,7 +282,8 @@ var formatters = { retry(entry) { const attempt = `Attempt: ${entry.attempt}${entry.total == Infinity ? "" : `/${entry.total}`}`; const delay = entry.delay > 0 ? `; next in ${entry.delay}ms` : ""; - return `${import_vendor_core.chalk.bgRed.white(" FAIL ")} ${attempt}${delay} + const reason = entry.exception instanceof Error ? ` \u2014 ${entry.exception.message.split("\n")[0]}` : ""; + return `${import_vendor_core.chalk.bgRed.white(" FAIL ")} ${attempt}${delay}${reason} `; }, end() { @@ -321,6 +322,7 @@ var RESERVED_WORDS = /* @__PURE__ */ new Set([ "EOF" ]); function formatCmd(cmd) { + var _a; if (cmd == void 0) return import_vendor_core.chalk.grey("undefined"); let q = ""; let out = "$ "; @@ -380,7 +382,9 @@ function formatCmd(cmd) { } } else { buf += c; - if (c === q) { + const backslashes = ((_a = buf.slice(0, -1).match(/\\+$/)) == null ? void 0 : _a[0].length) || 0; + const isEscaped = backslashes % 2 === 1; + if (c === q && !isEscaped) { cap(); q = ""; } @@ -753,6 +757,15 @@ var _ProcessPromise = class _ProcessPromise extends Promise { json() { return this.then((o) => o.json()); } + tryJson() { + return this.then((o) => o.tryJson()); + } + jsonl() { + return this.then((o) => o.jsonl()); + } + tryJsonl() { + return this.then((o) => o.tryJsonl()); + } text(encoding) { return this.then((o) => o.text(encoding)); } @@ -1029,7 +1042,43 @@ var _ProcessOutput = class _ProcessOutput extends Error { return !this._dto.error && this.exitCode === 0; } json() { - return JSON.parse(this.stdall); + try { + return JSON.parse(this.stdall); + } catch (e) { + const preview = this.stdall.length > 200 ? this.stdall.slice(0, 200) + "..." : this.stdall; + const formatted = preview.replace(/\n/g, "\u21B5"); + throw new SyntaxError( + `Failed to parse command output as JSON: ${formatted}` + ); + } + } + tryJson() { + try { + return JSON.parse(this.stdall); + } catch (e) { + return void 0; + } + } + jsonl() { + return this.lines().filter((line) => line.trim().length > 0).map((line, idx) => { + try { + return JSON.parse(line); + } catch (e) { + const preview = line.length > 100 ? line.slice(0, 100) + "..." : line; + throw new SyntaxError( + `Failed to parse JSONL line ${idx + 1}: ${preview}` + ); + } + }); + } + tryJsonl() { + return this.lines().filter((line) => line.trim().length > 0).map((line) => { + try { + return JSON.parse(line); + } catch (e) { + return void 0; + } + }); } buffer() { return import_node_buffer.Buffer.from(this.stdall); diff --git a/build/core.d.ts b/build/core.d.ts index d540fad7a2..2386390576 100644 --- a/build/core.d.ts +++ b/build/core.d.ts @@ -136,6 +136,9 @@ export declare class ProcessPromise extends Promise { get [Symbol.toStringTag](): string; [Symbol.toPrimitive](): string; json(): Promise; + tryJson(): Promise; + jsonl(): Promise; + tryJsonl(): Promise<(T | undefined)[]>; text(encoding?: Encoding): Promise; lines(delimiter?: Options['delimiter']): Promise; buffer(): Promise; @@ -189,6 +192,9 @@ export declare class ProcessOutput extends Error { get [Symbol.toStringTag](): string; get ok(): boolean; json(): T; + tryJson(): T | undefined; + jsonl(): T[]; + tryJsonl(): (T | undefined)[]; buffer(): Buffer; blob(type?: string): Blob; text(encoding?: Encoding): string; diff --git a/build/goods.d.ts b/build/goods.d.ts index d5fd7be828..57a3f0a941 100644 --- a/build/goods.d.ts +++ b/build/goods.d.ts @@ -16,6 +16,7 @@ export declare const parseArgv: (args?: string[], opts?: ArgvOpts, defs?: Record export declare function updateArgv(args?: string[], opts?: ArgvOpts): void; export declare const argv: minimist.ParsedArgs; export declare function sleep(duration: Duration): Promise; +export declare const responseToReadable: (response: Response, rs: Readable) => Readable; export declare function fetch(url: RequestInfo, init?: RequestInit): Promise & { pipe: { (dest: TemplateStringsArray, ...args: any[]): ProcessPromise; @@ -29,8 +30,12 @@ export declare function question(query?: string, { choices, input, output, }?: { output?: NodeJS.WriteStream; }): Promise; export declare function stdin(stream?: Readable): Promise; -export declare function retry(count: number, callback: () => T): Promise; -export declare function retry(count: number, duration: Duration | Generator, callback: () => T): Promise; -export declare function expBackoff(max?: Duration, delay?: Duration): Generator; +export interface RetryOptions { + delay?: Duration | Generator; + shouldRetry?: (err: unknown, attempt: number) => boolean; +} +export declare function retry(count: number, callback: (attempt: number, lastErr?: unknown) => T): Promise; +export declare function retry(count: number, durationOrOptions: Duration | Generator | RetryOptions, callback: (attempt: number, lastErr?: unknown) => T): Promise; +export declare function expBackoff(max?: Duration, delay?: Duration, jitter?: boolean | number): Generator; export declare function spinner(callback: () => T): Promise; export declare function spinner(title: string, callback: () => T): Promise; diff --git a/build/index.cjs b/build/index.cjs index 01b6c7afb9..9fc54775ba 100644 --- a/build/index.cjs +++ b/build/index.cjs @@ -29,6 +29,7 @@ __export(index_exports, { parseArgv: () => parseArgv, question: () => question, quiet: () => quiet, + responseToReadable: () => responseToReadable, retry: () => retry, sleep: () => sleep, spinner: () => spinner, @@ -108,8 +109,12 @@ var responseToReadable = (response, rs) => { return rs; } rs._read = () => __async(null, null, function* () { - const result = yield reader.read(); - rs.push(result.done ? null : import_node_buffer.Buffer.from(result.value)); + try { + const result = yield reader.read(); + rs.push(result.done ? null : import_node_buffer.Buffer.from(result.value)); + } catch (err) { + rs.destroy(err); + } }); return rs; }; @@ -125,12 +130,15 @@ function fetch(url, init) { })(dest, ...args) : dest; p.then( (r) => { - var _a; - return responseToReadable(r, rs).pipe((_a = _dest.run) == null ? void 0 : _a.call(_dest)); + const destStream = typeof (_dest == null ? void 0 : _dest.run) === "function" ? _dest.run() : _dest; + return responseToReadable(r, rs).pipe(destStream); }, (err) => { - var _a; - return (_a = _dest.abort) == null ? void 0 : _a.call(_dest, err); + if (typeof (_dest == null ? void 0 : _dest.abort) === "function") { + _dest.abort(err); + } else if (typeof (_dest == null ? void 0 : _dest.destroy) === "function") { + _dest.destroy(err); + } } ); return _dest; @@ -191,21 +199,34 @@ function stdin() { } function retry(count, d, cb) { return __async(this, null, function* () { + var _a, _b; if (typeof d === "function") return retry(count, 0, d); if (!cb) throw new import_core.Fail("Callback is required for retry"); + let duration = 0; + let shouldRetry; + if (typeof d === "object" && d !== null && !("next" in d) && !Array.isArray(d)) { + const opts = d; + duration = (_a = opts.delay) != null ? _a : 0; + shouldRetry = opts.shouldRetry; + } else { + duration = d; + } const total = count; - const gen = typeof d === "object" ? d : (function* (d2) { - while (true) yield d2; - })((0, import_util.parseDuration)(d)); + const gen = typeof duration === "object" && "next" in duration ? duration : (function* (dur) { + while (true) yield dur; + })((0, import_util.parseDuration)(duration)); let attempt = 0; let lastErr; while (count-- > 0) { attempt++; try { - return yield cb(); + return yield cb(attempt, lastErr); } catch (err) { lastErr = err; - const delay = gen.next().value; + if (shouldRetry && !shouldRetry(err, attempt)) { + throw err; + } + const delay = (_b = gen.next().value) != null ? _b : 0; import_core.$.log({ kind: "retry", total, @@ -222,12 +243,19 @@ function retry(count, d, cb) { throw lastErr; }); } -function* expBackoff(max = "60s", delay = "100ms") { +function* expBackoff(max = "60s", delay = "100ms", jitter = false) { const maxMs = (0, import_util.parseDuration)(max); const randMs = (0, import_util.parseDuration)(delay); + const jitterFactor = typeof jitter === "number" ? Math.min(Math.max(jitter, 0), 1) : jitter ? 0.2 : 0; let n = 0; while (true) { - yield Math.min(randMs * __pow(2, n++), maxMs); + const base = Math.min(randMs * __pow(2, n++), maxMs); + if (jitterFactor > 0) { + const delta = base * jitterFactor * (Math.random() * 2 - 1); + yield Math.max(0, Math.min(Math.round(base + delta), maxMs)); + } else { + yield base; + } } } function spinner(title, callback) { @@ -244,7 +272,10 @@ function spinner(title, callback) { return yield callback(); } finally { clearInterval(id); - stream.write(" ".repeat((import_node_process.default.stdout.columns || 1) - 1) + "\r"); + const c = stream.columns || import_node_process.default.stderr.columns || 80; + stream.write( + " ".repeat(Math.max(c, (title ? String(title).length : 0) + 10)) + "\r" + ); } })); }); @@ -281,6 +312,7 @@ function quiet(promise) { parseArgv, question, quiet, + responseToReadable, retry, sleep, spinner, diff --git a/build/index.js b/build/index.js index 7d19c66ae2..e67ad56b1c 100644 --- a/build/index.js +++ b/build/index.js @@ -18,6 +18,7 @@ const { parseArgv, question, quiet, + responseToReadable, retry, sleep, spinner, @@ -69,6 +70,7 @@ export { parseArgv, question, quiet, + responseToReadable, retry, sleep, spinner, diff --git a/build/util.cjs b/build/util.cjs index 1df79370d1..2da3d7ae67 100644 --- a/build/util.cjs +++ b/build/util.cjs @@ -64,7 +64,7 @@ function preferLocalBin(env, ...dirs) { function quote(arg) { if (arg === "") return `$''`; if (/^[\w/.\-+@:=,%]+$/.test(arg)) return arg; - return `$'` + arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\f/g, "\\f").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/\v/g, "\\v").replace(/\0/g, "\\0") + `'`; + return `$'` + arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\f/g, "\\f").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/\v/g, "\\v").replace(/\0/g, "\\0").replace(/\x07/g, "\\a").replace(/\x08/g, "\\b").replace(/\x1b/g, "\\e") + `'`; } function quotePowerShell(arg) { if (arg === "") return `''`; @@ -72,13 +72,23 @@ function quotePowerShell(arg) { return `'` + arg.replace(/'/g, "''") + `'`; } function parseDuration(d) { + var _a; if (typeof d === "number") { if (isNaN(d) || d < 0) throw new Error(`Invalid duration: "${d}".`); return d; } - const [m, v, u] = d.match(/^(\d+)(m?s?)$/) || []; - if (!m) throw new Error(`Unknown duration: "${d}".`); - return +v * ({ s: 1e3, ms: 1, m: 6e4 }[u] || 1); + const match = String(d).trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i); + if (!match) throw new Error(`Unknown duration: "${d}".`); + const val = parseFloat(match[1]); + const unit = (match[2] || "ms").toLowerCase(); + const multipliers = { + ms: 1, + s: 1e3, + m: 6e4, + h: 36e5, + d: 864e5 + }; + return val * ((_a = multipliers[unit]) != null ? _a : 1); } var once = (fn) => { let called = false; @@ -91,7 +101,7 @@ var proxyOverride = (origin, ...fallbacks) => new Proxy(origin, { return (_b = (_a = fallbacks.find((f) => key in f)) == null ? void 0 : _a[key]) != null ? _b : Reflect.get(target, key); } }); -var toCamelCase = (str) => str.toLowerCase().replace(/([a-z])[_-]+([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase()); +var toCamelCase = (str) => str.toLowerCase().replace(/^[_-]+/, "").replace(/([a-z0-9])[_-]+([a-z0-9])/g, (_, p1, p2) => p1 + p2.toUpperCase()); var parseBool = (v) => v === "true" || v !== "false" && v; var getLines = (chunk, next, delimiter) => { const lines = ((next.pop() || "") + bufToString(chunk)).split(delimiter); diff --git a/build/util.d.ts b/build/util.d.ts index 0cbcdf197a..f0f5703e6f 100644 --- a/build/util.d.ts +++ b/build/util.d.ts @@ -16,7 +16,7 @@ export declare function preferLocalBin(env: NodeJS.ProcessEnv, ...dirs: (string }; export declare function quote(arg: string): string; export declare function quotePowerShell(arg: string): string; -export type Duration = number | `${number}` | `${number}m` | `${number}s` | `${number}ms`; +export type Duration = number | `${number}` | `${number}d` | `${number}h` | `${number}m` | `${number}s` | `${number}ms`; export declare function parseDuration(d: Duration): number; export declare const once: any>(fn: T) => (...args: Parameters) => ReturnType; export declare const proxyOverride: (origin: T, ...fallbacks: any) => T; diff --git a/scripts/build-js.mjs b/scripts/build-js.mjs index e7228ee935..b3cbec581c 100644 --- a/scripts/build-js.mjs +++ b/scripts/build-js.mjs @@ -16,6 +16,7 @@ import path from 'node:path' import fs from 'node:fs' +import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' import { injectCode, injectFile } from 'esbuild-plugin-utils' import { nodeExternalsPlugin } from 'esbuild-node-externals' @@ -27,7 +28,7 @@ import esbuildResolvePlugin from 'esbuild-plugin-resolve' import minimist from 'minimist' import glob from 'fast-glob' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const argv = minimist(process.argv.slice(2), { default: { diff --git a/scripts/build-jsr.mjs b/scripts/build-jsr.mjs index 5975a51e23..5b7f2c7d3f 100644 --- a/scripts/build-jsr.mjs +++ b/scripts/build-jsr.mjs @@ -14,7 +14,8 @@ import fs from 'node:fs' import path from 'node:path' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +import { fileURLToPath } from 'node:url' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const pkgJson = JSON.parse( fs.readFileSync(path.resolve(root, 'package.json'), 'utf-8') diff --git a/scripts/build-pkgjson-lite.mjs b/scripts/build-pkgjson-lite.mjs index ec433df6c4..d866ff2604 100644 --- a/scripts/build-pkgjson-lite.mjs +++ b/scripts/build-pkgjson-lite.mjs @@ -16,9 +16,10 @@ import fs from 'node:fs' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { depseekSync } from 'depseek' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const source = 'package.json' const dest = 'package-lite.json' diff --git a/scripts/build-pkgjson-main.mjs b/scripts/build-pkgjson-main.mjs index 4786832b55..645507e3d4 100644 --- a/scripts/build-pkgjson-main.mjs +++ b/scripts/build-pkgjson-main.mjs @@ -16,8 +16,9 @@ import fs from 'node:fs' import path from 'node:path' +import { fileURLToPath } from 'node:url' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const source = 'package.json' const dest = 'package-main.json' diff --git a/scripts/build-size-limit.mjs b/scripts/build-size-limit.mjs index f1ab224008..c9eaf943b9 100644 --- a/scripts/build-size-limit.mjs +++ b/scripts/build-size-limit.mjs @@ -16,9 +16,10 @@ import fs from 'node:fs' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { spawnSync } from 'node:child_process' -const root = path.resolve(new URL(import.meta.url).pathname, '../..') +const root = path.resolve(fileURLToPath(import.meta.url), '../..') const configPath = path.join(root, '.size-limit.json') const original = fs.readFileSync(configPath, 'utf8') const config = JSON.parse(original) diff --git a/scripts/build-tests.mjs b/scripts/build-tests.mjs index 35a773e5f5..a46e536a30 100644 --- a/scripts/build-tests.mjs +++ b/scripts/build-tests.mjs @@ -20,13 +20,15 @@ import * as core from '../build/core.js' import * as cli from '../build/cli.js' import * as index from '../build/index.js' +import { fileURLToPath } from 'node:url' + // prettier-ignore const modules = [ ['core', core], ['cli', cli], ['index', index], ] -const root = path.resolve(new URL(import.meta.url).pathname, '../..') +const root = path.resolve(fileURLToPath(import.meta.url), '../..') const filePath = path.resolve(root, `test/export.test.js`) const copyright = fs.readFileSync( diff --git a/scripts/build-versions.mjs b/scripts/build-versions.mjs index aea3e2a6bf..41e2a954a3 100644 --- a/scripts/build-versions.mjs +++ b/scripts/build-versions.mjs @@ -16,9 +16,10 @@ import fs from 'fs-extra' import path from 'node:path' +import { fileURLToPath } from 'node:url' import minimist from 'minimist' -const root = path.resolve(new URL(import.meta.url).pathname, '../..') +const root = path.resolve(fileURLToPath(import.meta.url), '../..') const copyright = fs.readFileSync( path.resolve(root, 'test/fixtures/copyright.txt'), 'utf8' diff --git a/src/cli.ts b/src/cli.ts index df1ea719f0..a0a0e7ddb5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -169,6 +169,7 @@ async function runScript( // TODO: fix unanalyzable-dynamic-import to work correctly with jsr.io await import(url.pathToFileURL(scriptPath).toString()) } finally { + process.off('exit', rmTemp) rmTemp() } } diff --git a/src/core.ts b/src/core.ts index 39c320149f..87d0fcc78d 100644 --- a/src/core.ts +++ b/src/core.ts @@ -578,6 +578,18 @@ export class ProcessPromise extends Promise { return this.then((o) => o.json()) } + tryJson(): Promise { + return this.then((o) => o.tryJson()) + } + + jsonl(): Promise { + return this.then((o) => o.jsonl()) + } + + tryJsonl(): Promise<(T | undefined)[]> { + return this.then((o) => o.tryJsonl()) + } + text(encoding?: Encoding): Promise { return this.then((o) => o.text(encoding)) } @@ -934,7 +946,53 @@ export class ProcessOutput extends Error { } json(): T { - return JSON.parse(this.stdall) + try { + return JSON.parse(this.stdall) + } catch { + const preview = + this.stdall.length > 200 + ? this.stdall.slice(0, 200) + '...' + : this.stdall + const formatted = preview.replace(/\n/g, '↵') + throw new SyntaxError( + `Failed to parse command output as JSON: ${formatted}` + ) + } + } + + tryJson(): T | undefined { + try { + return JSON.parse(this.stdall) + } catch { + return undefined + } + } + + jsonl(): T[] { + return this.lines() + .filter((line) => line.trim().length > 0) + .map((line, idx) => { + try { + return JSON.parse(line) + } catch { + const preview = line.length > 100 ? line.slice(0, 100) + '...' : line + throw new SyntaxError( + `Failed to parse JSONL line ${idx + 1}: ${preview}` + ) + } + }) + } + + tryJsonl(): (T | undefined)[] { + return this.lines() + .filter((line) => line.trim().length > 0) + .map((line) => { + try { + return JSON.parse(line) + } catch { + return undefined + } + }) } buffer(): Buffer { diff --git a/src/error.ts b/src/error.ts index 2425c4e779..c9e2296b2d 100644 --- a/src/error.ts +++ b/src/error.ts @@ -223,7 +223,9 @@ export class Fail extends Error { .split(/^\s*(at\s)?/m) .filter((s) => s?.includes(':')) const i = lines.findIndex((l) => l.includes('Proxy.set')) - const offset = i < 0 ? i : i + 2 + // When Proxy.set is not found, skip the error header (e.g. "Error: msg") + // and use the first actual frame line instead. + const offset = i < 0 ? lines.findIndex((l) => /:\d+/.test(l)) : i + 2 return ( lines.find((l) => l.includes('file://')) || diff --git a/src/goods.ts b/src/goods.ts index 72fb8e8a27..a0785d95a8 100644 --- a/src/goods.ts +++ b/src/goods.ts @@ -104,15 +104,19 @@ export function sleep(duration: Duration): Promise { }) } -const responseToReadable = (response: Response, rs: Readable) => { +export const responseToReadable = (response: Response, rs: Readable) => { const reader = response.body?.getReader() if (!reader) { rs.push(null) return rs } rs._read = async () => { - const result = await reader.read() - rs.push(result.done ? null : Buffer.from(result.value)) + try { + const result = await reader.read() + rs.push(result.done ? null : Buffer.from(result.value)) + } catch (err) { + rs.destroy(err as Error) + } } return rs } @@ -139,8 +143,18 @@ export function fetch( })(dest as TemplateStringsArray, ...args) : dest p.then( - (r) => responseToReadable(r, rs).pipe(_dest.run?.()), - (err) => _dest.abort?.(err) + (r) => { + const destStream = + typeof _dest?.run === 'function' ? _dest.run() : _dest + return responseToReadable(r, rs).pipe(destStream) + }, + (err) => { + if (typeof _dest?.abort === 'function') { + _dest.abort(err) + } else if (typeof _dest?.destroy === 'function') { + _dest.destroy(err) + } + } ) return _dest }, @@ -202,37 +216,68 @@ export async function stdin(stream: Readable = process.stdin): Promise { return buf } -export async function retry(count: number, callback: () => T): Promise +export interface RetryOptions { + delay?: Duration | Generator + shouldRetry?: (err: unknown, attempt: number) => boolean +} + +export async function retry( + count: number, + callback: (attempt: number, lastErr?: unknown) => T +): Promise export async function retry( count: number, - duration: Duration | Generator, - callback: () => T + durationOrOptions: Duration | Generator | RetryOptions, + callback: (attempt: number, lastErr?: unknown) => T ): Promise export async function retry( count: number, - d: Duration | Generator | (() => T), - cb?: () => T + d: + | Duration + | Generator + | RetryOptions + | ((attempt: number, lastErr?: unknown) => T), + cb?: (attempt: number, lastErr?: unknown) => T ): Promise { if (typeof d === 'function') return retry(count, 0, d) if (!cb) throw new Fail('Callback is required for retry') + let duration: Duration | Generator = 0 + let shouldRetry: ((err: unknown, attempt: number) => boolean) | undefined + + if ( + typeof d === 'object' && + d !== null && + !('next' in d) && + !Array.isArray(d) + ) { + const opts = d as RetryOptions + duration = opts.delay ?? 0 + shouldRetry = opts.shouldRetry + } else { + duration = d as Duration | Generator + } + const total = count const gen = - typeof d === 'object' - ? d - : (function* (d) { - while (true) yield d - })(parseDuration(d)) + typeof duration === 'object' && 'next' in duration + ? duration + : (function* (dur) { + while (true) yield dur + })(parseDuration(duration as Duration)) let attempt = 0 let lastErr: unknown while (count-- > 0) { attempt++ try { - return await cb() + return await cb(attempt, lastErr) } catch (err) { lastErr = err - const delay = gen.next().value + if (shouldRetry && !shouldRetry(err, attempt)) { + throw err + } + const delay = gen.next().value ?? 0 $.log({ kind: 'retry', @@ -251,13 +296,26 @@ export async function retry( export function* expBackoff( max: Duration = '60s', - delay: Duration = '100ms' + delay: Duration = '100ms', + jitter: boolean | number = false ): Generator { const maxMs = parseDuration(max) const randMs = parseDuration(delay) + const jitterFactor = + typeof jitter === 'number' + ? Math.min(Math.max(jitter, 0), 1) + : jitter + ? 0.2 + : 0 let n = 0 while (true) { - yield Math.min(randMs * 2 ** n++, maxMs) + const base = Math.min(randMs * 2 ** n++, maxMs) + if (jitterFactor > 0) { + const delta = base * jitterFactor * (Math.random() * 2 - 1) + yield Math.max(0, Math.min(Math.round(base + delta), maxMs)) + } else { + yield base + } } } @@ -281,7 +339,10 @@ export async function spinner( return await callback!() } finally { clearInterval(id as ReturnType) - stream.write(' '.repeat((process.stdout.columns || 1) - 1) + '\r') + const c = (stream as any).columns || process.stderr.columns || 80 + stream.write( + ' '.repeat(Math.max(c, (title ? String(title).length : 0) + 10)) + '\r' + ) } }) } diff --git a/src/log.ts b/src/log.ts index cb61d32175..45ec8cf437 100644 --- a/src/log.ts +++ b/src/log.ts @@ -101,8 +101,12 @@ const formatters: LogFormatters = { retry(entry) { const attempt = `Attempt: ${entry.attempt}${entry.total == Infinity ? '' : `/${entry.total}`}` const delay = entry.delay > 0 ? `; next in ${entry.delay}ms` : '' + const reason = + entry.exception instanceof Error + ? ` — ${entry.exception.message.split('\n')[0]}` + : '' - return `${chalk.bgRed.white(' FAIL ')} ${attempt}${delay}\n` + return `${chalk.bgRed.white(' FAIL ')} ${attempt}${delay}${reason}\n` }, end() { return '' @@ -211,7 +215,9 @@ export function formatCmd(cmd: string): string { } } else { buf += c - if (c === q) { + const backslashes = buf.slice(0, -1).match(/\\+$/)?.[0].length || 0 + const isEscaped = backslashes % 2 === 1 + if (c === q && !isEscaped) { cap() q = '' } diff --git a/src/md.ts b/src/md.ts index 2b6932e8ad..9e159244cd 100644 --- a/src/md.ts +++ b/src/md.ts @@ -17,7 +17,7 @@ import { bufToString } from './util.ts' export function transformMarkdown(buf: Buffer | string): string { const out: string[] = [] - const tabRe = /^( +|\t)/ + const tabRe = /^( {4,}|\t)/ const fenceRe = /^(? {0,3})(?(`{3,20}|~{3,20}))(?:(?js|javascript|ts|typescript)|(?sh|shell|bash)|.*)$/ @@ -66,19 +66,19 @@ export function transformMarkdown(buf: Buffer | string): string { continue } - prevEmpty = line === '' + prevEmpty = line.trim() === '' out.push('// ' + line) continue } case 'tab': - if (line === '') out.push('') + if (line.trim() === '') out.push('') else if (tabRe.test(line)) out.push(line) else { out.push('// ' + line) state = 'root' } - prevEmpty = line === '' + prevEmpty = line.trim() === '' break case 'fence': diff --git a/src/util.ts b/src/util.ts index 1a272905ba..9a630f9d91 100644 --- a/src/util.ts +++ b/src/util.ts @@ -86,7 +86,10 @@ export function quote(arg: string): string { .replace(/\r/g, '\\r') .replace(/\t/g, '\\t') .replace(/\v/g, '\\v') - .replace(/\0/g, '\\0') + + .replace(/\0/g, '\\0') + .replace(/\x07/g, '\\a') + .replace(/\x08/g, '\\b') + .replace(/\x1b/g, '\\e') + `'` ) } @@ -99,17 +102,36 @@ export function quotePowerShell(arg: string): string { } export type Duration = - number | `${number}` | `${number}m` | `${number}s` | `${number}ms` - -export function parseDuration(d: Duration) { + | number + | `${number}` + | `${number}d` + | `${number}h` + | `${number}m` + | `${number}s` + | `${number}ms` + +export function parseDuration(d: Duration): number { if (typeof d === 'number') { if (isNaN(d) || d < 0) throw new Error(`Invalid duration: "${d}".`) return d } - const [m, v, u] = d.match(/^(\d+)(m?s?)$/) || [] - if (!m) throw new Error(`Unknown duration: "${d}".`) + const match = String(d) + .trim() + .match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i) + if (!match) throw new Error(`Unknown duration: "${d}".`) + + const val = parseFloat(match[1]) + const unit = (match[2] || 'ms').toLowerCase() + + const multipliers: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + } - return +v * ({ s: 1000, ms: 1, m: 60_000 }[u] || 1) + return val * (multipliers[unit] ?? 1) } export const once = any>(fn: T) => { @@ -135,7 +157,8 @@ export const proxyOverride = ( export const toCamelCase = (str: string) => str .toLowerCase() - .replace(/([a-z])[_-]+([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase()) + .replace(/^[_-]+/, '') + .replace(/([a-z0-9])[_-]+([a-z0-9])/g, (_, p1, p2) => p1 + p2.toUpperCase()) export const parseBool = (v: string): boolean | string => v === 'true' || (v !== 'false' && v) diff --git a/test/cli.test.js b/test/cli.test.js index 848cade891..a9342638df 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -356,7 +356,8 @@ console.log(a); }) test('exceptions are caught', async () => { - const out1 = await $`node build/cli.js <<<${'await $`wtf`'}`.nothrow() + const out1 = + await $`node build/cli.js <<<${'await $`non_existent_cmd_zx`'}`.nothrow() const out2 = await $`node build/cli.js <<<'throw 42'`.nothrow() assert.match(out1.stderr, /Error:/) assert.match(out2.stderr, /42/) @@ -378,21 +379,27 @@ console.log(a); const toPOSIXPath = (_path) => _path.split(path.sep).join(path.posix.sep) const zxPath = path.resolve('./build/cli.js') - const zxLocation = isWindows ? toPOSIXPath(zxPath) : zxPath - const scriptCode = `#!/usr/bin/env ${zxLocation}\nconsole.log('The script from path runs.')` const scriptName = 'script-from-path' - const scriptFile = tmpfile(scriptName, scriptCode, 0o744) + const scriptFile = tmpfile(scriptName, '', 0o744) const scriptDir = path.dirname(scriptFile) + const zxWrapper = path.join(scriptDir, isWindows ? 'zx.cmd' : 'zx') + const zxWrapperCode = isWindows + ? `@node "${zxPath}" %*` + : `#!/bin/sh\nexec node "${zxPath}" "$@"` + await fs.writeFile(zxWrapper, zxWrapperCode, { mode: 0o755 }) + + const scriptCode = `#!/usr/bin/env zx\nconsole.log('The script from path runs.')` + await fs.writeFile(scriptFile, scriptCode, { mode: 0o744 }) const envPathSeparator = isWindows ? ';' : ':' process.env.PATH += envPathSeparator + scriptDir try { - await $`chmod +x ${zxLocation}` await $`${scriptName}` } finally { process.env.PATH = oldPath await fs.rm(scriptFile) + await fs.rm(zxWrapper) } }) diff --git a/test/core.test.js b/test/core.test.js index b80a2eda91..99841e51e1 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -253,14 +253,14 @@ describe('core', () => { test('$ thrown as error', async () => { let err try { - await $`wtf` + await $`non_existent_cmd_zx` } catch (p) { err = p } assert.ok(err.exitCode > 0) assert.match(err.toString(), /command not found/) assert.match(err.valueOf(), /command not found/) - assert.match(err.stderr, /wtf: command not found/) + assert.match(err.stderr, /non_existent_cmd_zx: command not found/) assert.match(err[inspect.custom](), /Command not found/) }) @@ -1413,6 +1413,31 @@ describe('core', () => { test('json()', async () => { assert.deepEqual(await $`echo '{"key":"value"}'`.json(), { key: 'value' }) + const err = await $`echo 'not json'`.nothrow() + assert.throws( + () => err.json(), + /Failed to parse command output as JSON: not json/ + ) + }) + + test('tryJson()', async () => { + assert.deepEqual(await $`echo '{"key":"value"}'`.tryJson(), { + key: 'value', + }) + const err = await $`echo 'not json'`.nothrow() + assert.equal(err.tryJson(), undefined) + }) + + test('jsonl()', async () => { + const res = await $`echo '{"a":1}\n{"b":2}'`.jsonl() + assert.deepEqual(res, [{ a: 1 }, { b: 2 }]) + const err = await $`echo '{"a":1}\ninvalid'`.nothrow() + assert.throws(() => err.jsonl(), /Failed to parse JSONL line 2: invalid/) + }) + + test('tryJsonl()', async () => { + const res = await $`echo '{"a":1}\ninvalid\n{"b":2}'`.tryJsonl() + assert.deepEqual(res, [{ a: 1 }, undefined, { b: 2 }]) }) test('text()', async () => { diff --git a/test/deps.test.js b/test/deps.test.js index 1443867114..6ffc23b985 100644 --- a/test/deps.test.js +++ b/test/deps.test.js @@ -14,10 +14,11 @@ import assert from 'node:assert' import { test, describe } from 'node:test' +import { fileURLToPath } from 'node:url' import { $, tmpfile, tmpdir, fs, path } from '../build/index.js' import { installDeps, parseDeps } from '../build/deps.cjs' -const __dirname = new URL('.', import.meta.url).pathname +const __dirname = fileURLToPath(new URL('.', import.meta.url)) const root = path.resolve(__dirname, '..') const cli = path.resolve(root, 'build/cli.js') diff --git a/test/error.test.ts b/test/error.test.ts index 50e6ed8e8a..e5dd926e41 100644 --- a/test/error.test.ts +++ b/test/error.test.ts @@ -38,10 +38,7 @@ describe('error', () => { }) test('getCallerLocation()', () => { - assert.match( - getCallerLocation(new Error('Foo')), - /TestContext\./ - ) + assert.match(getCallerLocation(new Error('Foo')), /error\.test\.ts:\d+:\d+/) }) describe('getCallerLocationFromString()', () => { @@ -56,6 +53,16 @@ describe('error', () => { ) }) + test('no Proxy.set falls back to first frame', () => { + const stack = ` + Error + at foo (/Users/user/test.js:5:10) + at bar (/Users/user/test.js:10:3) + at Object. (/Users/user/test.js:15:1) + ` + assert.match(getCallerLocationFromString(stack), /^.*:5:10.*$/) + }) + test(`getCallerLocationFromString-v8`, () => { const stack = ` Error diff --git a/test/export.test.js b/test/export.test.js index eea5abebe6..7f10734573 100644 --- a/test/export.test.js +++ b/test/export.test.js @@ -242,12 +242,15 @@ describe('index', () => { assert.equal(typeof index.fs, 'object', 'index.fs') assert.equal(typeof index.fs.Dir, 'function', 'index.fs.Dir') assert.equal(typeof index.fs.Dirent, 'function', 'index.fs.Dirent') + assert.equal(typeof index.fs.F_OK, 'number', 'index.fs.F_OK') assert.equal(typeof index.fs.FileReadStream, 'function', 'index.fs.FileReadStream') assert.equal(typeof index.fs.FileWriteStream, 'function', 'index.fs.FileWriteStream') + assert.equal(typeof index.fs.R_OK, 'number', 'index.fs.R_OK') assert.equal(typeof index.fs.ReadStream, 'function', 'index.fs.ReadStream') assert.equal(typeof index.fs.Stats, 'function', 'index.fs.Stats') - assert.equal(typeof index.fs.Utf8Stream, 'function', 'index.fs.Utf8Stream') + assert.equal(typeof index.fs.W_OK, 'number', 'index.fs.W_OK') assert.equal(typeof index.fs.WriteStream, 'function', 'index.fs.WriteStream') + assert.equal(typeof index.fs.X_OK, 'number', 'index.fs.X_OK') assert.equal(typeof index.fs._toUnixTimestamp, 'function', 'index.fs._toUnixTimestamp') assert.equal(typeof index.fs.access, 'function', 'index.fs.access') assert.equal(typeof index.fs.accessSync, 'function', 'index.fs.accessSync') @@ -323,7 +326,6 @@ describe('index', () => { assert.equal(typeof index.fs.mkdirs, 'function', 'index.fs.mkdirs') assert.equal(typeof index.fs.mkdirsSync, 'function', 'index.fs.mkdirsSync') assert.equal(typeof index.fs.mkdtemp, 'function', 'index.fs.mkdtemp') - assert.equal(typeof index.fs.mkdtempDisposableSync, 'function', 'index.fs.mkdtempDisposableSync') assert.equal(typeof index.fs.mkdtempSync, 'function', 'index.fs.mkdtempSync') assert.equal(typeof index.fs.move, 'function', 'index.fs.move') assert.equal(typeof index.fs.moveSync, 'function', 'index.fs.moveSync') @@ -471,6 +473,7 @@ describe('index', () => { assert.equal(typeof index.quote, 'function', 'index.quote') assert.equal(typeof index.quotePowerShell, 'function', 'index.quotePowerShell') assert.equal(typeof index.resolveDefaults, 'function', 'index.resolveDefaults') + assert.equal(typeof index.responseToReadable, 'function', 'index.responseToReadable') assert.equal(typeof index.retry, 'function', 'index.retry') assert.equal(typeof index.sleep, 'function', 'index.sleep') assert.equal(typeof index.spinner, 'function', 'index.spinner') diff --git a/test/extra.test.js b/test/extra.test.js index f74d82fe33..d9270b1364 100644 --- a/test/extra.test.js +++ b/test/extra.test.js @@ -14,9 +14,10 @@ import assert from 'node:assert' import { test, describe } from 'node:test' +import { fileURLToPath } from 'node:url' import { globby, fs, path } from '../build/index.js' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) describe('extra', () => { test('every file should have a license', async () => { diff --git a/test/goods.test.ts b/test/goods.test.ts index 87dedae7bf..876921984c 100644 --- a/test/goods.test.ts +++ b/test/goods.test.ts @@ -33,11 +33,13 @@ import { tmpdir, tmpfile, versions, + responseToReadable, } from '../src/goods.ts' -import { Writable } from 'node:stream' +import { Writable, Readable } from 'node:stream' import process from 'node:process' +import { fileURLToPath } from 'node:url' -const __dirname = new URL('.', import.meta.url).pathname +const __dirname = fileURLToPath(new URL('.', import.meta.url)) const root = path.resolve(__dirname, '..') describe('goods', () => { @@ -129,6 +131,22 @@ describe('goods', () => { assert.equal(count, 5) }) + test('passes attempt number and lastErr to callback', async () => { + const attempts: number[] = [] + const errors: (unknown | undefined)[] = [] + const result = await retry(3, '2ms', (attempt, lastErr) => { + attempts.push(attempt) + errors.push(lastErr) + if (attempt < 3) throw new Error(`fail-${attempt}`) + return 'done' + }) + assert.equal(result, 'done') + assert.deepEqual(attempts, [1, 2, 3]) + assert.equal(errors[0], undefined) + assert.match((errors[1] as Error).message, /fail-1/) + assert.match((errors[2] as Error).message, /fail-2/) + }) + test('works with custom delay and limit', async () => { const now = Date.now() let count = 0 @@ -170,6 +188,32 @@ describe('goods', () => { assert.equal(result, 'success') }) + test('supports expBackoff with jitter', () => { + const gen = expBackoff('100ms', '10ms', true) + const val1 = gen.next().value as number + assert.ok(val1 >= 0 && val1 <= 20) + }) + + test('supports RetryOptions with shouldRetry predicate', async () => { + let attempts = 0 + try { + await retry( + 5, + { + delay: '2ms', + shouldRetry: (err: any) => err.message !== 'fatal', + }, + () => { + attempts++ + throw new Error('fatal') + } + ) + } catch (err: any) { + assert.equal(err.message, 'fatal') + assert.equal(attempts, 1) + } + }) + test('integration', async () => { const now = Date.now() const p = await zx(` @@ -190,7 +234,7 @@ describe('goods', () => { const now = Date.now() const p = await zx(` try { - await retry(5, expBackoff('60s', 0), () => $\`exit 123\`) + await retry(5, expBackoff('60s', '1ms'), () => $\`exit 123\`) } catch (e) { echo('exitCode:', e.exitCode) } @@ -198,7 +242,7 @@ describe('goods', () => { `) assert.ok(p.toString().includes('exitCode: 123')) assert.ok(p.toString().includes('success')) - assert.ok(Date.now() >= now + 2 + 4 + 8 + 16 + 32) + assert.ok(Date.now() >= now + 1 + 2 + 4 + 8 + 16) }) }) @@ -365,6 +409,44 @@ describe('goods', () => { assert(p3.includes('GitHub')) }) + test('fetch().pipe to Writable stream', async () => { + let data = '' + const dest = new Writable({ + write(chunk, _enc, cb) { + data += chunk.toString() + cb() + }, + }) + const req = fetch('https://github.com/') + req.pipe(dest) + await req + await new Promise((resolve) => dest.on('finish', resolve)) + assert(data.length > 0) + }) + + test('responseToReadable handles stream error gracefully', async () => { + const mockResponse = { + body: { + getReader() { + return { + async read() { + throw new Error('Stream error') + }, + } + }, + }, + } as unknown as Response + + const rs = responseToReadable(mockResponse, new Readable()) + await assert.rejects( + new Promise((_, reject) => { + rs.on('error', reject) + rs._read() + }), + /Stream error/ + ) + }) + describe('dotenv', () => { test('parse()', () => { assert.deepEqual(dotenv.parse(''), {}) diff --git a/test/it/build-jsr.test.js b/test/it/build-jsr.test.js index 52bd1d6de9..de9dd8c04e 100644 --- a/test/it/build-jsr.test.js +++ b/test/it/build-jsr.test.js @@ -14,8 +14,9 @@ import { tempdir, $, path, fs } from '../../build/index.js' import { describe, before, after, it } from 'node:test' +import { fileURLToPath } from 'node:url' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '../../') describe('jsr artifact', () => { diff --git a/test/it/build-npm.test.js b/test/it/build-npm.test.js index f80026044f..2f03368508 100644 --- a/test/it/build-npm.test.js +++ b/test/it/build-npm.test.js @@ -14,6 +14,7 @@ import assert from 'node:assert' import { describe, test } from 'node:test' +import { fileURLToPath } from 'node:url' import { $, within, @@ -24,7 +25,7 @@ import { version, } from '../../build/index.js' -const __dirname = new URL('.', import.meta.url).pathname +const __dirname = fileURLToPath(new URL('.', import.meta.url)) const root = path.resolve(__dirname, '../..') const sync = async (from, to, entries) => { for (const entry of entries) diff --git a/test/log.test.ts b/test/log.test.ts index 7e1631a845..1c4685baab 100644 --- a/test/log.test.ts +++ b/test/log.test.ts @@ -109,7 +109,22 @@ describe('log', () => { }) assert.equal( data.join(''), - '\x1B[41m\x1B[37m FAIL \x1B[39m\x1B[49m Attempt: 1/3; next in 1000ms\n' + '\x1B[41m\x1B[37m FAIL \x1B[39m\x1B[49m Attempt: 1/3; next in 1000ms — foo\n' + ) + }) + + test('retry with non-Error exception omits reason', () => { + log({ + kind: 'retry', + attempt: 2, + total: 5, + delay: 0, + exception: 'string error', + verbose: true, + }) + assert.equal( + data.join(''), + '\x1B[41m\x1B[37m FAIL \x1B[39m\x1B[49m Attempt: 2/5\n' ) }) @@ -182,7 +197,11 @@ describe('log', () => { `echo '\n str\n'`, "$ \x1B[92mecho\x1B[39m \x1B[93m'\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[93m str\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[93m'\x1B[39m\n", ], - [`$'\\''`, "$ \x1B[93m$\x1B[39m\x1B[93m'\\'\x1B[39m\x1B[93m'\x1B[39m\n"], + [`$'\\''`, "$ \x1B[93m$\x1B[39m\x1B[93m'\\''\x1B[39m\n"], + [ + `echo "hello \\"world\\""`, + '$ \x1B[92mecho\x1B[39m \x1B[93m"hello \\"world\\""\x1B[39m\n', + ], [ 'sass-compiler --style=compressed src/static/bootstrap.scss > dist/static/bootstrap-v5.3.3.min.css', '$ \x1B[92msass-compiler\x1B[39m --style\x1B[31m=\x1B[39mcompressed src/static/bootstrap.scss \x1B[31m>\x1B[39m\x1B[92m dist/static/bootstrap-v5.3.3.min.css\x1B[39m\n', diff --git a/test/md.test.ts b/test/md.test.ts index d37b9c9f6e..7e4e41f2a4 100644 --- a/test/md.test.ts +++ b/test/md.test.ts @@ -22,8 +22,18 @@ describe('transformMarkdown()', () => { assert.equal(transformMarkdown('\n'), '// \n// ') }) - test('preserves tab-indented blocks after a blank line (legacy behavior)', () => { - assert.equal(transformMarkdown(' \n '), ' \n ') + test('comments out 2-3 space indented text and list continuations', () => { + assert.equal( + transformMarkdown('\n Two spaces text\n Three spaces text'), + '// \n// Two spaces text\n// Three spaces text' + ) + }) + + test('preserves 4-space and tab-indented blocks after a blank line', () => { + assert.equal( + transformMarkdown('\n code\n\tcode'), + '// \n code\n\tcode' + ) }) test('does not treat a mid-paragraph fence as a fenced block (legacy behavior)', () => { diff --git a/test/util.test.js b/test/util.test.js index fba510fcab..8980dd3fc2 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -70,7 +70,9 @@ describe('util', () => { test('quote()', () => { assert.ok(quote('string') === 'string') assert.ok(quote('') === `$''`) - assert.ok(quote(`'\f\n\r\t\v\0`) === `$'\\'\\f\\n\\r\\t\\v\\0'`) + assert.ok( + quote(`'\f\n\r\t\v\0\x07\x08\x1b`) === `$'\\'\\f\\n\\r\\t\\v\\0\\a\\b\\e'` + ) const allowed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_/.-+@:=,%' @@ -92,8 +94,15 @@ describe('util', () => { assert.equal(parseDuration(1000), 1000) assert.equal(parseDuration('100'), 100) assert.equal(parseDuration('2s'), 2000) + assert.equal(parseDuration('1.5s'), 1500) + assert.equal(parseDuration('0.5s'), 500) assert.equal(parseDuration('500ms'), 500) assert.equal(parseDuration('2m'), 120000) + assert.equal(parseDuration('1h'), 3600000) + assert.equal(parseDuration('1d'), 86400000) + assert.equal(parseDuration('1.5 s'), 1500) + assert.equal(parseDuration('500 MS'), 500) + assert.equal(parseDuration('2 H'), 7200000) assert.throws(() => parseDuration('f2ms')) assert.throws(() => parseDuration('2mss')) assert.throws(() => parseDuration(NaN)) @@ -123,6 +132,8 @@ describe('util', () => { assert.equal(toCamelCase('PREFER_LOCAL'), 'preferLocal') assert.equal(toCamelCase('SOME_MORE_BIG_STR'), 'someMoreBigStr') assert.equal(toCamelCase('kebab-input-str'), 'kebabInputStr') + assert.equal(toCamelCase('HTTP_PORT_8080'), 'httpPort8080') + assert.equal(toCamelCase('--foo-bar-123'), 'fooBar123') }) test('parseBool()', () => {