diff --git a/commands/exec.js b/commands/exec.js index cb52857..6d0ee89 100644 --- a/commands/exec.js +++ b/commands/exec.js @@ -1,5 +1,7 @@ -import { getXmlRpcClient } from '@existdb/node-exist' +import { getXmlRpcClient, getRestClient } from '@existdb/node-exist' import { readFileSync } from 'node:fs' +import { createInterface } from 'node:readline' +import WebSocket from 'ws' /** * parse bindings @@ -63,19 +65,337 @@ function getQuery (file, query) { } /** - * query db, output to standard out + * Build WebSocket URL from connection options + * @param {object} connectionOptions + * @returns {string} WebSocket URL + */ +export function getWsUrl (connectionOptions) { + const { host, port } = connectionOptions + const wsProtocol = connectionOptions.protocol === 'https:' ? 'wss' : 'ws' + return `${wsProtocol}://${host}:${port}/exist/ws/eval` +} + +/** + * Build auth header from connection options + * @param {object} connectionOptions + * @returns {string} Basic auth header value + */ +export function getAuthHeader (connectionOptions) { + const { user, pass } = connectionOptions.basic_auth + return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64') +} + +/** + * Escape a query string for embedding as an XQuery string literal. + * Uses XQuery's double-quote escaping: " becomes "" + * @param {string} query the query to escape + * @returns {string} escaped query wrapped in double quotes + */ +export function escapeXQuery (query) { + return '"' + query.replace(/"/g, '""') + '"' +} + +/** + * Build XQuery to evaluate a query and return a cursor + * @param {string} query the user's query + * @returns {string} wrapper XQuery calling lsp:eval + */ +export function buildEvalQuery (query) { + return 'import module namespace lsp="http://exist-db.org/xquery/lsp";\n' + + 'lsp:eval(' + escapeXQuery(query) + ', "xmldb:exist:///db")' +} + +/** + * Build serialization options from CLI flags + * @param {object} argv parsed CLI arguments + * @returns {object} serialization options map + */ +export function buildSerializationOptions (argv) { + const options = {} + if (argv.method) options.method = argv.method + if (argv.indent !== undefined) options.indent = argv.indent ? 'yes' : 'no' + if (argv.highlight) options['highlight-matches'] = 'elements' + return options +} + +/** + * Serialize an options object as an XQuery map literal + * @param {object} options key-value pairs + * @returns {string} XQuery map expression, or empty string if no options + */ +export function serializeXQueryMap (options) { + const entries = Object.entries(options) + if (entries.length === 0) return '' + const pairs = entries.map(([k, v]) => '"' + k + '": "' + v + '"') + return 'map { ' + pairs.join(', ') + ' }' +} + +/** + * Build XQuery to fetch a page of results from a cursor + * @param {string} cursorId the cursor identifier + * @param {number} start 1-based start index + * @param {number} count number of items to fetch + * @param {object} [options] serialization options + * @returns {string} wrapper XQuery calling lsp:fetch + */ +export function buildFetchQuery (cursorId, start, count, options) { + const optionsArg = options ? serializeXQueryMap(options) : '' + const args = '"' + cursorId + '", ' + start + ', ' + count + + (optionsArg ? ', ' + optionsArg : '') + return 'import module namespace lsp="http://exist-db.org/xquery/lsp";\n' + + 'lsp:fetch(' + args + ')' +} + +/** + * Prompt the user for input on stderr + * @param {string} message the prompt message + * @returns {Promise} the user's answer + */ +function promptUser (message) { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stderr + }) + rl.question(message + ' ', (answer) => { + rl.close() + resolve(answer) + }) + }) +} + +/** + * Execute query via cursor-based pagination using REST API * - * @param {NodeExist.BoundModules} db bound NodeExist modules + * @param {object} connectionOptions + * @param {string} query the query to execute + * @param {number} pageSize number of results per page + * @param {string} outputFormat output format ('text' or 'json') + * @param {object} serializationOptions serialization options for lsp:fetch + * @param {boolean} showTiming whether to show timing info + * @returns {Promise} exit code + */ +async function executeCursor (connectionOptions, query, pageSize, outputFormat, serializationOptions, showTiming) { + const restClient = getRestClient(connectionOptions) + + // Evaluate query and obtain cursor + const evalResult = await restClient.get('exist/rest/db', { + _query: buildEvalQuery(query), + _wrap: 'no' + }) + const cursor = JSON.parse(evalResult.bodyText) + const totalHits = cursor.hits ?? cursor.summary?.hits ?? 0 + + if (showTiming && cursor.timing) { + const t = cursor.timing + const parts = [] + if (t.parse != null) parts.push('Parse: ' + t.parse + 'ms') + if (t.compile != null) parts.push('Compile: ' + t.compile + 'ms') + if (t.evaluate != null) parts.push('Eval: ' + t.evaluate + 'ms') + if (t.total != null) parts.push('Total: ' + t.total + 'ms') + if (parts.length) console.error(parts.join(' | ')) + } + + if (totalHits === 0) { + if (outputFormat === 'json') { + console.log('[]') + } + return 0 + } + + const totalPages = Math.ceil(totalHits / pageSize) + const isTTY = process.stdout.isTTY + const jsonResults = outputFormat === 'json' ? [] : null + + let page = 1 + while (page <= totalPages) { + const start = (page - 1) * pageSize + 1 + const hasOptions = Object.keys(serializationOptions).length > 0 + const fetchQuery = hasOptions + ? buildFetchQuery(cursor.cursor, start, pageSize, serializationOptions) + : buildFetchQuery(cursor.cursor, start, pageSize) + const fetchResult = await restClient.get('exist/rest/db', { + _query: fetchQuery, + _wrap: 'no' + }) + + if (jsonResults) { + // Collect results for JSON output + try { + const parsed = JSON.parse(fetchResult.bodyText) + if (Array.isArray(parsed)) { + jsonResults.push(...parsed) + } else { + jsonResults.push(parsed) + } + } catch { + // If not parseable as JSON, store as string + jsonResults.push(fetchResult.bodyText.trim()) + } + } else { + process.stdout.write(fetchResult.bodyText) + if (!fetchResult.bodyText.endsWith('\n')) { + process.stdout.write('\n') + } + } + + if (page >= totalPages) break + + if (!isTTY || jsonResults) { + // When piped or collecting JSON, fetch all pages without prompting + page++ + continue + } + + const answer = await promptUser( + '[Page ' + page + ' of ' + totalPages + '. Press Enter for next page, q to quit]' + ) + if (answer.toLowerCase() === 'q') break + page++ + } + + if (jsonResults) { + console.log(JSON.stringify(jsonResults, null, 2)) + } + + return 0 +} + +/** + * Execute query via HTTP, output to standard out + * + * @param {object} db bound NodeExist modules * @param {string|Buffer} query the query to execute * @param {object} variables the bound variables + * @param {boolean} showTiming whether to show timing info * @returns {Promise} exit code */ -async function execute (db, query, variables) { +async function execute (db, query, variables, showTiming) { + const start = performance.now() const result = await db.queries.readAll(query, { variables }) + const total = performance.now() - start + console.log(result.pages.toString()) + + if (showTiming) { + console.error(`Total: ${Math.round(total)}ms`) + } + return 0 } +/** + * Execute query via WebSocket with streaming output + * + * @param {object} connectionOptions + * @param {string} query the query to execute + * @param {object} serializationOptions serialization options + * @param {boolean} showTiming whether to show timing info + * @returns {Promise} exit code + */ +function executeStream (connectionOptions, query, serializationOptions, showTiming) { + return new Promise((resolve, reject) => { + const wsUrl = getWsUrl(connectionOptions) + const ws = new WebSocket(wsUrl, { + headers: { Authorization: getAuthHeader(connectionOptions) } + }) + + const requestId = crypto.randomUUID() + let itemCount = 0 + const start = performance.now() + + function printTiming (timing) { + if (!showTiming) return + if (timing) { + const parts = [] + if (timing.parse != null) parts.push(`Parse: ${timing.parse}ms`) + if (timing.compile != null) parts.push(`Compile: ${timing.compile}ms`) + if (timing.evaluate != null) parts.push(`Eval: ${timing.evaluate}ms`) + if (timing.serialize != null) parts.push(`Serialize: ${timing.serialize}ms`) + if (timing.total != null) parts.push(`Total: ${timing.total}ms`) + console.error(parts.join(' | ')) + } else { + console.error(`Total: ${Math.round(performance.now() - start)}ms`) + } + } + + function handleCancel () { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'cancel', id: requestId })) + } + } + + process.on('SIGINT', handleCancel) + + ws.on('open', () => { + const msg = { action: 'eval', id: requestId, query } + if (Object.keys(serializationOptions).length > 0) { + msg.serialization = serializationOptions + } + ws.send(JSON.stringify(msg)) + }) + + ws.on('message', (data) => { + let msg + try { + msg = JSON.parse(data.toString()) + } catch { + process.stdout.write(data.toString()) + itemCount++ + return + } + + if (msg.type === 'error') { + console.error(msg.message) + printTiming(msg.timing) + ws.close() + resolve(1) + return + } + + if (msg.type === 'cancelled') { + const elapsed = ((performance.now() - start) / 1000).toFixed(1) + const count = msg.items != null ? msg.items : itemCount + console.error(`Cancelled after ${count.toLocaleString()} items (${elapsed}s)`) + printTiming(msg.timing) + ws.close() + resolve(1) + return + } + + if (msg.type === 'result') { + if (msg.data != null) { + process.stdout.write(msg.data) + if (!msg.data.endsWith('\n')) { + process.stdout.write('\n') + } + } + itemCount = msg.items != null ? msg.items : itemCount + 1 + if (!msg.more) { + printTiming(msg.timing) + ws.close() + resolve(0) + } + } + + // ignore progress messages silently + }) + + ws.on('error', (err) => { + process.removeListener('SIGINT', handleCancel) + if (err.code === 'ECONNREFUSED') { + reject(Error(`WebSocket connection refused at ${wsUrl}`)) + } else { + reject(err) + } + }) + + ws.on('close', () => { + process.removeListener('SIGINT', handleCancel) + }) + }) +} + export const command = ['execute [] [options]', 'run', 'exec'] export const describe = 'Execute a query string or file' @@ -94,6 +414,44 @@ export async function builder (yargs) { coerce: parseBindings, default: () => {} }) + .option('s', { + alias: 'stream', + type: 'boolean', + describe: 'Stream results via WebSocket as they arrive', + default: false + }) + .option('t', { + alias: 'timing', + type: 'boolean', + describe: 'Show execution timing', + default: false + }) + .option('page-size', { + type: 'number', + describe: 'Number of results per page (cursor mode)', + default: 20 + }) + .option('o', { + alias: 'output', + type: 'string', + describe: 'Output format (text, json)', + choices: ['text', 'json'], + default: 'text' + }) + .option('method', { + type: 'string', + describe: 'Serialization method (xml, json, text, html, adaptive)', + choices: ['xml', 'json', 'text', 'html', 'adaptive'] + }) + .option('indent', { + type: 'boolean', + describe: 'Indent serialized output' + }) + .option('highlight', { + type: 'boolean', + describe: 'Highlight full-text matches in results', + default: false + }) .option('h', { alias: 'help', type: 'boolean' }) .nargs({ f: 1, b: 1 }) .conflicts('f', 'query') @@ -104,9 +462,26 @@ export async function handler (argv) { if (argv.help) { return 0 } - const { file, bind, query } = argv + const { file, bind, query, stream, timing, pageSize, output, connectionOptions } = argv const _query = getQuery(file, query) - const db = getXmlRpcClient(argv.connectionOptions) + const serOpts = buildSerializationOptions(argv) + + if (stream) { + return await executeStream(connectionOptions, _query, serOpts, timing) + } + + const db = getXmlRpcClient(connectionOptions) + + // Use cursor-based pagination when no variables are bound + const hasBindings = bind && Object.keys(bind).length > 0 + if (!hasBindings) { + try { + return await executeCursor(connectionOptions, _query, pageSize, output, serOpts, timing) + } catch { + // Fall back to XML-RPC if cursor API is not available + return await execute(db, _query, bind, timing) + } + } - return await execute(db, _query, bind) + return await execute(db, _query, bind, timing) } diff --git a/package-lock.json b/package-lock.json index f13acb3..5f188b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "find-up-simple": "^1.0.1", "semver": "^7.7.3", "undici": "^7.19.2", + "ws": "^8.20.0", "yargs": "^18.0.0" }, "bin": { @@ -10923,6 +10924,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/package.json b/package.json index dfcc9cf..6ec9611 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "find-up-simple": "^1.0.1", "semver": "^7.7.3", "undici": "^7.19.2", + "ws": "^8.20.0", "yargs": "^18.0.0" }, "devDependencies": { diff --git a/spec/tests/exec.js b/spec/tests/exec.js index 3e03271..6cec61c 100644 --- a/spec/tests/exec.js +++ b/spec/tests/exec.js @@ -1,7 +1,8 @@ import { test } from 'tape' import yargs from 'yargs' import * as exec from '../../commands/exec.js' -import { runPipe } from '../test.js' +import { getWsUrl, getAuthHeader, escapeXQuery, buildEvalQuery, buildFetchQuery, buildSerializationOptions, serializeXQueryMap } from '../../commands/exec.js' +import { run, runPipe, asAdmin } from '../test.js' const parser = yargs().scriptName('xst').command(exec).help().fail(false) @@ -138,3 +139,394 @@ test('read query from stdin', async function (t) { if (stderr) { return t.fail(stderr) } t.equals('2\n', stdout) }) + +// --stream flag parsing + +test('parses --stream flag', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--stream', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.plan(2) + t.equal(argv.stream, true) + t.equal(argv.query, '1+1') +}) + +test('parses -s shorthand for --stream', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '-s', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.stream, true) +}) + +test('--stream defaults to false', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.stream, false) +}) + +// --timing flag parsing + +test('parses --timing flag', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--timing', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.plan(2) + t.equal(argv.timing, true) + t.equal(argv.query, '1+1') +}) + +test('parses -t shorthand for --timing', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '-t', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.timing, true) +}) + +test('--timing defaults to false', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.timing, false) +}) + +test('parses --stream and --timing together', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--stream', '--timing', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.plan(3) + t.equal(argv.stream, true) + t.equal(argv.timing, true) + t.equal(argv.query, '1+1') +}) + +// WebSocket URL construction + +test('getWsUrl builds ws:// URL from http connection', function (t) { + const url = getWsUrl({ protocol: 'http:', host: 'localhost', port: 8080 }) + t.equal(url, 'ws://localhost:8080/exist/ws/eval') + t.end() +}) + +test('getWsUrl builds wss:// URL from https connection', function (t) { + const url = getWsUrl({ protocol: 'https:', host: 'example.com', port: 8443 }) + t.equal(url, 'wss://example.com:8443/exist/ws/eval') + t.end() +}) + +// Auth header construction + +test('getAuthHeader builds Basic auth header', function (t) { + const header = getAuthHeader({ basic_auth: { user: 'admin', pass: '' } }) + const expected = 'Basic ' + Buffer.from('admin:').toString('base64') + t.equal(header, expected) + t.end() +}) + +test('getAuthHeader encodes user and pass', function (t) { + const header = getAuthHeader({ basic_auth: { user: 'joe', pass: 's3cret' } }) + const expected = 'Basic ' + Buffer.from('joe:s3cret').toString('base64') + t.equal(header, expected) + t.end() +}) + +// --page-size flag parsing + +test('parses --page-size flag', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--page-size', '50', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.plan(2) + t.equal(argv.pageSize, 50) + t.equal(argv.query, '1+1') +}) + +test('--page-size defaults to 20', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.pageSize, 20) +}) + +// --output flag parsing + +test('parses --output json flag', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--output', 'json', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.plan(2) + t.equal(argv.output, 'json') + t.equal(argv.query, '1+1') +}) + +test('parses -o shorthand for --output', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '-o', 'json', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.output, 'json') +}) + +test('--output defaults to text', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.output, 'text') +}) + +test('--output rejects invalid values', async function (t) { + try { + await new Promise((resolve, reject) => { + parser.parse(['exec', '--output', 'xml', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.fail('should have thrown') + } catch (e) { + t.ok(e, 'rejects invalid output format') + } +}) + +// XQuery escaping + +test('escapeXQuery wraps in double quotes', function (t) { + t.equal(escapeXQuery('1+1'), '"1+1"') + t.end() +}) + +test('escapeXQuery escapes internal double quotes', function (t) { + t.equal(escapeXQuery('let $x := "hello"'), '"let $x := ""hello"""') + t.end() +}) + +test('escapeXQuery handles empty string', function (t) { + t.equal(escapeXQuery(''), '""') + t.end() +}) + +// buildEvalQuery + +test('buildEvalQuery produces lsp:eval wrapper', function (t) { + const result = buildEvalQuery('1+1') + t.ok(result.includes('import module namespace lsp='), 'imports lsp module') + t.ok(result.includes('lsp:eval("1+1"'), 'calls lsp:eval with escaped query') + t.ok(result.includes('"xmldb:exist:///db"'), 'includes context URI') + t.end() +}) + +// buildFetchQuery + +test('buildFetchQuery produces lsp:fetch wrapper', function (t) { + const result = buildFetchQuery('abc-123', 1, 20) + t.ok(result.includes('import module namespace lsp='), 'imports lsp module') + t.ok(result.includes('lsp:fetch("abc-123", 1, 20)'), 'calls lsp:fetch with cursor, start, count') + t.end() +}) + +test('buildFetchQuery handles different page parameters', function (t) { + const result = buildFetchQuery('xyz', 21, 50) + t.ok(result.includes('lsp:fetch("xyz", 21, 50)'), 'uses correct start and count') + t.end() +}) + +test('buildFetchQuery includes serialization options', function (t) { + const result = buildFetchQuery('abc', 1, 20, { method: 'json', indent: 'no' }) + t.ok(result.includes('map {'), 'includes options map') + t.ok(result.includes('"method": "json"'), 'includes method option') + t.ok(result.includes('"indent": "no"'), 'includes indent option') + t.end() +}) + +test('buildFetchQuery omits options map when empty', function (t) { + const result = buildFetchQuery('abc', 1, 20) + t.notOk(result.includes('map {'), 'no options map without options arg') + t.end() +}) + +// serializeXQueryMap + +test('serializeXQueryMap produces XQuery map literal', function (t) { + const result = serializeXQueryMap({ method: 'xml', indent: 'yes' }) + t.equal(result, 'map { "method": "xml", "indent": "yes" }') + t.end() +}) + +test('serializeXQueryMap returns empty string for empty object', function (t) { + t.equal(serializeXQueryMap({}), '') + t.end() +}) + +test('serializeXQueryMap handles highlight-matches', function (t) { + const result = serializeXQueryMap({ 'highlight-matches': 'elements' }) + t.equal(result, 'map { "highlight-matches": "elements" }') + t.end() +}) + +// buildSerializationOptions + +test('buildSerializationOptions extracts method', function (t) { + const opts = buildSerializationOptions({ method: 'json' }) + t.deepEqual(opts, { method: 'json' }) + t.end() +}) + +test('buildSerializationOptions converts indent true to yes', function (t) { + const opts = buildSerializationOptions({ indent: true }) + t.deepEqual(opts, { indent: 'yes' }) + t.end() +}) + +test('buildSerializationOptions converts indent false to no', function (t) { + const opts = buildSerializationOptions({ indent: false }) + t.deepEqual(opts, { indent: 'no' }) + t.end() +}) + +test('buildSerializationOptions sets highlight-matches', function (t) { + const opts = buildSerializationOptions({ highlight: true }) + t.deepEqual(opts, { 'highlight-matches': 'elements' }) + t.end() +}) + +test('buildSerializationOptions returns empty object when no flags', function (t) { + const opts = buildSerializationOptions({}) + t.deepEqual(opts, {}) + t.end() +}) + +test('buildSerializationOptions combines all flags', function (t) { + const opts = buildSerializationOptions({ method: 'xml', indent: false, highlight: true }) + t.deepEqual(opts, { method: 'xml', indent: 'no', 'highlight-matches': 'elements' }) + t.end() +}) + +// Serialization flag parsing + +test('parses --method flag', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--method', 'json', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.method, 'json') +}) + +test('--method rejects invalid values', async function (t) { + try { + await new Promise((resolve, reject) => { + parser.parse(['exec', '--method', 'csv', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.fail('should have thrown') + } catch (e) { + t.ok(e, 'rejects invalid method') + } +}) + +test('parses --indent flag', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--indent', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.indent, true) +}) + +test('parses --no-indent flag', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--no-indent', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.indent, false) +}) + +test('parses --highlight flag', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '--highlight', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.highlight, true) +}) + +test('--highlight defaults to false', async function (t) { + const argv = await new Promise((resolve, reject) => { + parser.parse(['exec', '1+1'], (err, argv, output) => { + if (err) { return reject(err) } + resolve(argv) + }) + }) + t.equal(argv.highlight, false) +}) + +// Integration tests (require running eXist-db) + +test('--timing prints timing to stderr', async function (t) { + const { stdout, stderr } = await run('xst', ['exec', '--timing', '1+1'], asAdmin) + t.equal(stdout, '2\n', 'query result on stdout') + t.ok(stderr && stderr.match(/Total: \d+ms/), 'timing on stderr') +}) + +test('piped output fetches all pages without prompting', async function (t) { + const { stdout, stderr } = await run('xst', ['exec', '--page-size', '5', 'for $i in 1 to 12 return $i'], asAdmin) + t.notOk(stderr, 'no pagination prompt on stderr') + t.ok(stdout, 'produces output') + // Should contain all 12 results without any interactive prompt + const lines = stdout.trim().split('\n').filter(l => l.trim() !== '') + t.ok(lines.length >= 1, 'has result lines') +}) + +test('--method text produces raw text output', async function (t) { + const { stdout, stderr } = await run('xst', ['exec', '--method', 'text', 'string-join(("a","b","c"), ",")'], asAdmin) + if (stderr) { return t.fail(stderr) } + t.equal(stdout.trim(), 'a,b,c', 'text serialization returns raw values') +}) + +test('--indent no suppresses indentation', async function (t) { + const { stdout, stderr } = await run('xst', ['exec', '--no-indent', ''], asAdmin) + if (stderr) { return t.fail(stderr) } + t.notOk(stdout.includes('