diff --git a/src/commands/search.ts b/src/commands/search.ts index 95dd008..72a52aa 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -9,6 +9,46 @@ import { parseBetween, buildDateFilter } from '../date-filter.ts'; export type SearchResult = { items: Array>; total?: number }; +/** + * Flags that are valid globally (not specific to any search resource). + */ +export const GLOBAL_FLAGS = new Set([ + 'profile', 'sortBy', 'asc', 'desc', 'help', 'version', +]); + +/** + * Valid search filter flags per resource (values keys that are consumed by the search handler). + * This map is used to detect when a user passes a flag that looks valid but is not recognized + * for the specific resource, causing silent filter drops. + */ +export const SEARCH_RESOURCE_FLAGS: Record> = { + 'process-definition': new Set(['bpmnProcessId', 'id', 'processDefinitionId', 'name', 'key', 'iid', 'iname']), + 'process-instance': new Set(['bpmnProcessId', 'id', 'processDefinitionId', 'processDefinitionKey', 'state', 'key', 'parentProcessInstanceKey', 'iid']), + 'user-task': new Set(['state', 'assignee', 'processInstanceKey', 'processDefinitionKey', 'elementId', 'iassignee']), + 'incident': new Set(['state', 'processInstanceKey', 'processDefinitionKey', 'bpmnProcessId', 'id', 'processDefinitionId', 'errorType', 'errorMessage', 'ierrorMessage', 'iid']), + 'jobs': new Set(['state', 'type', 'processInstanceKey', 'processDefinitionKey', 'itype']), + 'variable': new Set(['name', 'value', 'processInstanceKey', 'scopeKey', 'fullValue', 'iname', 'ivalue', 'limit']), +}; + +/** + * Detect flags the user set that are not recognized for the given search resource. + * Returns the list of unknown flag names (without the --prefix). + */ +export function detectUnknownSearchFlags(values: Record, normalizedResource: string): string[] { + const validFlags = SEARCH_RESOURCE_FLAGS[normalizedResource] + || SEARCH_RESOURCE_FLAGS[normalizedResource.replace(/s$/, '')]; + if (!validFlags) return []; + + const unknown: string[] = []; + for (const [key, val] of Object.entries(values)) { + if (val === undefined || val === false) continue; // not set by the user + if (GLOBAL_FLAGS.has(key)) continue; + if (validFlags.has(key)) continue; + unknown.push(key); + } + return unknown; +} + /** * Detect wildcard characters (* or ?) in a string value and return * a $like filter object for the API. Returns the plain string for exact match. @@ -71,6 +111,9 @@ const toBigIntSafe = (value: unknown): bigint => { } }; +/** Default page size the Camunda REST API uses when no explicit limit is set */ +export const API_DEFAULT_PAGE_SIZE = 100; + /** Max page size for case-insensitive search (client-side filtering needs broader result set) */ const CI_PAGE_SIZE = 1000; @@ -111,7 +154,7 @@ function formatCriterion(fieldLabel: string, value: string | number | boolean, i */ function logSearchCriteria(logger: Logger, resourceName: string, criteria: string[]): void { if (criteria.length === 0) { - logger.info(`Searching ${resourceName}`); + logger.info(`Searching ${resourceName} (no filters)`); } else if (criteria.length === 1) { logger.info(`Searching ${resourceName} where ${criteria[0]}`); } else { @@ -119,6 +162,33 @@ function logSearchCriteria(logger: Logger, resourceName: string, criteria: strin } } +/** + * Log a "no results" message with 🕳️ emoji and contextual hint. + */ +export function logNoResults(logger: Logger, resourceName: string, hasFilters: boolean, unknownFlags?: string[]): void { + if (unknownFlags && unknownFlags.length > 0) { + const flagList = unknownFlags.map(f => `--${f}`).join(', '); + logger.info(`🕳️ No ${resourceName} found matching the criteria (ignored unknown flag(s): ${flagList})`); + } else { + logger.info(`🕳️ No ${resourceName} found matching the criteria`); + } + if (!hasFilters) { + logger.info('No filters were applied. Use "c8ctl help search" to see available filter flags.'); + } +} + +/** + * Log the result count with a truncation warning when the count matches the API default page size. + */ +export function logResultCount(logger: Logger, count: number, resourceName: string, hasFilters: boolean): void { + logger.info(`Found ${count} ${resourceName}`); + if (count === API_DEFAULT_PAGE_SIZE && !hasFilters) { + logger.warn(`Showing first ${API_DEFAULT_PAGE_SIZE} results (API default page size). More results may exist — add filters to narrow down.`); + } else if (count === API_DEFAULT_PAGE_SIZE) { + logger.warn(`Result count equals the API default page size (${API_DEFAULT_PAGE_SIZE}). There may be more results.`); + } +} + /** * Search process definitions */ @@ -132,6 +202,7 @@ export async function searchProcessDefinitions(options: { iName?: string; sortBy?: string; sortOrder?: SortOrder; + _unknownFlags?: string[]; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -218,9 +289,9 @@ export async function searchProcessDefinitions(options: { })); tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder); logger.table(tableData); - logger.info(`Found ${result.items.length} process definition(s)`); + logResultCount(logger, result.items.length, 'process definition(s)', criteria.length > 0); } else { - logger.info('No process definitions found matching the criteria'); + logNoResults(logger, 'process definitions', criteria.length > 0, options._unknownFlags); } return result as SearchResult; @@ -243,6 +314,7 @@ export async function searchProcessInstances(options: { iProcessDefinitionId?: string; sortBy?: string; sortOrder?: SortOrder; + _unknownFlags?: string[]; between?: string; dateField?: string; }): Promise { @@ -336,9 +408,9 @@ export async function searchProcessInstances(options: { })); tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder); logger.table(tableData); - logger.info(`Found ${result.items.length} process instance(s)`); + logResultCount(logger, result.items.length, 'process instance(s)', criteria.length > 0); } else { - logger.info('No process instances found matching the criteria'); + logNoResults(logger, 'process instances', criteria.length > 0, options._unknownFlags); } return result as SearchResult; @@ -361,6 +433,7 @@ export async function searchUserTasks(options: { iAssignee?: string; sortBy?: string; sortOrder?: SortOrder; + _unknownFlags?: string[]; between?: string; dateField?: string; }): Promise { @@ -455,9 +528,9 @@ export async function searchUserTasks(options: { })); tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder); logger.table(tableData); - logger.info(`Found ${result.items.length} user task(s)`); + logResultCount(logger, result.items.length, 'user task(s)', criteria.length > 0); } else { - logger.info('No user tasks found matching the criteria'); + logNoResults(logger, 'user tasks', criteria.length > 0, options._unknownFlags); } return result as SearchResult; @@ -482,6 +555,7 @@ export async function searchIncidents(options: { iProcessDefinitionId?: string; sortBy?: string; sortOrder?: SortOrder; + _unknownFlags?: string[]; between?: string; }): Promise { const logger = getLogger(); @@ -587,9 +661,9 @@ export async function searchIncidents(options: { })); tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder); logger.table(tableData); - logger.info(`Found ${result.items.length} incident(s)`); + logResultCount(logger, result.items.length, 'incident(s)', criteria.length > 0); } else { - logger.info('No incidents found matching the criteria'); + logNoResults(logger, 'incidents', criteria.length > 0, options._unknownFlags); } return result as SearchResult; @@ -611,6 +685,7 @@ export async function searchJobs(options: { iType?: string; sortBy?: string; sortOrder?: SortOrder; + _unknownFlags?: string[]; between?: string; dateField?: string; }): Promise { @@ -698,9 +773,9 @@ export async function searchJobs(options: { })); tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder); logger.table(tableData); - logger.info(`Found ${result.items.length} job(s)`); + logResultCount(logger, result.items.length, 'job(s)', criteria.length > 0); } else { - logger.info('No jobs found matching the criteria'); + logNoResults(logger, 'jobs', criteria.length > 0, options._unknownFlags); } return result as SearchResult; @@ -725,6 +800,7 @@ export async function searchVariables(options: { sortBy?: string; sortOrder?: SortOrder; limit?: number; + _unknownFlags?: string[]; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -833,13 +909,13 @@ export async function searchVariables(options: { }); tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder); logger.table(tableData); - logger.info(`Found ${result.items.length} variable(s)`); + logResultCount(logger, result.items.length, 'variable(s)', criteria.length > 0); if (!options.fullValue && result.items.some((v: any) => v.isTruncated)) { logger.info('Some values are truncated. Use --fullValue to see full values.'); } } else { - logger.info('No variables found matching the criteria'); + logNoResults(logger, 'variables', criteria.length > 0, options._unknownFlags); } return result as SearchResult; diff --git a/src/index.ts b/src/index.ts index 4459e0c..0295562 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,9 @@ * Main entry point */ +import { realpathSync } from 'node:fs'; import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; import { getLogger, type SortOrder } from './logger.ts'; import { c8ctl } from './runtime.ts'; import { loadSessionState } from './config.ts'; @@ -28,6 +30,7 @@ import { searchIncidents, searchJobs, searchVariables, + detectUnknownSearchFlags, } from './commands/search.ts'; import { listUserTasks, completeUserTask } from './commands/user-tasks.ts'; import { listIncidents, getIncident, resolveIncident } from './commands/incidents.ts'; @@ -144,6 +147,17 @@ export function resolveProcessDefinitionId(values: any): string | undefined { return (values.id || values.processDefinitionId || values.bpmnProcessId) as string | undefined; } +/** + * Warn about unrecognized flags for a search resource. + */ +function warnUnknownSearchFlags(logger: ReturnType, unknownFlags: string[], resource: string): void { + if (unknownFlags.length === 0) return; + const flagList = unknownFlags.map(f => `--${f}`).join(', '); + logger.warn( + `Flag(s) ${flagList} not recognized for 'search ${resource}'. They will be ignored. Run "c8ctl help search" for valid options.`, + ); +} + /** * Main CLI handler */ @@ -666,18 +680,21 @@ async function main() { // Handle search commands if (verb === 'search') { const normalizedSearchResource = normalizeResource(resource); + const unknownFlags = detectUnknownSearchFlags(values as Record, normalizedSearchResource); + warnUnknownSearchFlags(logger, unknownFlags, resource); if (normalizedSearchResource === 'process-definition' || normalizedSearchResource === 'process-definitions') { await searchProcessDefinitions({ profile: values.profile as string | undefined, processDefinitionId: resolveProcessDefinitionId(values), name: values.name as string | undefined, - version: (values.version_num && typeof values.version_num === 'string') ? parseInt(values.version_num) : undefined, + version: (values.version && typeof values.version === 'string') ? parseInt(values.version) : undefined, key: values.key as string | undefined, iProcessDefinitionId: values.iid as string | undefined, iName: values.iname as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + _unknownFlags: unknownFlags, }); return; } @@ -693,6 +710,7 @@ async function main() { iProcessDefinitionId: values.iid as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + _unknownFlags: unknownFlags, between: values.between as string | undefined, dateField: values.dateField as string | undefined, }); @@ -710,6 +728,7 @@ async function main() { iAssignee: values.iassignee as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + _unknownFlags: unknownFlags, between: values.between as string | undefined, dateField: values.dateField as string | undefined, }); @@ -729,6 +748,7 @@ async function main() { iProcessDefinitionId: values.iid as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + _unknownFlags: unknownFlags, between: values.between as string | undefined, }); return; @@ -744,6 +764,7 @@ async function main() { iType: values.itype as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + _unknownFlags: unknownFlags, between: values.between as string | undefined, dateField: values.dateField as string | undefined, }); @@ -763,6 +784,7 @@ async function main() { sortBy: values.sortBy as string | undefined, sortOrder, limit, + _unknownFlags: unknownFlags, }); return; } @@ -790,9 +812,12 @@ async function main() { } // Run the CLI only when invoked directly (not when imported) -if (process.argv[1] === new URL(import.meta.url).pathname) { - main().catch((error) => { - console.error('Unexpected error:', error); - process.exit(1); - }); -} +// Use realpathSync to resolve symlinks (e.g. when installed globally via npm link) +try { + if (realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error('Unexpected error:', error); + process.exit(1); + }); + } +} catch { /* not invoked directly */ } diff --git a/tests/integration/global-install.test.ts b/tests/integration/global-install.test.ts new file mode 100644 index 0000000..a245279 --- /dev/null +++ b/tests/integration/global-install.test.ts @@ -0,0 +1,101 @@ +/** + * Integration tests for global install / symlink invocation + * Verifies the built binary works when invoked through a symlink, + * as happens with `npm install -g` or `npm link`. + * + * npm creates a symlink from the global bin directory to the package's + * dist/index.js. Node resolves the symlink, so process.argv[1] is the + * symlink path while import.meta.url resolves to the real file. The + * entry guard in index.ts must handle this via realpathSync. + * + * NOTE: These tests require a built binary (dist/index.js). They are + * automatically skipped when dist/index.js is not present. + * Run `npm run build` to enable them. + */ + +import { test, describe, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, rmSync, symlinkSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; + +const projectRoot = resolve(import.meta.dirname, '..', '..'); +const distEntry = join(projectRoot, 'dist', 'index.js'); +const skipReason = existsSync(distEntry) ? undefined : 'dist/index.js not found - run "npm run build" first'; + +describe('Global Install (symlink) Integration Tests', { skip: skipReason }, () => { + let tempBinDir: string; + let symlinkPath: string; + + beforeEach(() => { + tempBinDir = join(tmpdir(), `c8ctl-global-test-${Date.now()}`); + mkdirSync(tempBinDir, { recursive: true }); + symlinkPath = join(tempBinDir, 'c8ctl.js'); + }); + + afterEach(() => { + if (existsSync(tempBinDir)) { + rmSync(tempBinDir, { recursive: true, force: true }); + } + }); + + test('binary works when invoked directly with node', () => { + const result = spawnSync('node', [distEntry, 'help'], { + encoding: 'utf-8', + timeout: 10_000, + }); + + assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('c8ctl'), 'help output should mention c8ctl'); + assert.ok(result.stdout.includes('Usage:'), 'help output should contain Usage'); + }); + + test('binary works when node receives a symlink as argv[1] (simulates npm link / npm install -g)', () => { + symlinkSync(distEntry, symlinkPath); + + // Use `node ` — this is how npm global installs work: + // the shebang causes node to be invoked with the symlink path as argv[1] + const result = spawnSync('node', [symlinkPath, 'help'], { + encoding: 'utf-8', + timeout: 10_000, + }); + + assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('c8ctl'), 'help output should mention c8ctl'); + assert.ok(result.stdout.includes('Usage:'), 'help output should contain Usage'); + }); + + test('binary shows version when invoked through a symlink', () => { + symlinkSync(distEntry, symlinkPath); + + const result = spawnSync('node', [symlinkPath, 'help'], { + encoding: 'utf-8', + timeout: 10_000, + }); + + assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`); + // Version line appears in help output header + assert.match(result.stdout, /v\d+\.\d+\.\d+/, 'output should contain a version string'); + }); + + test('binary works through a double symlink (symlink -> symlink -> dist/index.js)', () => { + // Simulates npm global bin -> node_modules symlink -> project dist + const intermediateDir = join(tempBinDir, 'node_modules'); + mkdirSync(intermediateDir, { recursive: true }); + const intermediatePath = join(intermediateDir, 'index.js'); + + // First symlink: intermediate -> real file + symlinkSync(distEntry, intermediatePath); + // Second symlink: bin entry -> intermediate + symlinkSync(intermediatePath, symlinkPath); + + const result = spawnSync('node', [symlinkPath, 'help'], { + encoding: 'utf-8', + timeout: 10_000, + }); + + assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('c8ctl'), 'help output should mention c8ctl'); + }); +}); diff --git a/tests/unit/search-feedback.test.ts b/tests/unit/search-feedback.test.ts new file mode 100644 index 0000000..63025e9 --- /dev/null +++ b/tests/unit/search-feedback.test.ts @@ -0,0 +1,323 @@ +/** + * Unit tests for search feedback improvements: + * - Unknown flag detection (detectUnknownSearchFlags) + * - Empty result messaging with 🕳️ (logNoResults) + * - Truncation / page-size warnings (logResultCount) + * - No-filter hints + * - GLOBAL_FLAGS and SEARCH_RESOURCE_FLAGS validation + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { + detectUnknownSearchFlags, + logNoResults, + logResultCount, + GLOBAL_FLAGS, + SEARCH_RESOURCE_FLAGS, +} from '../../src/commands/search.ts'; +import { Logger, type LogWriter } from '../../src/logger.ts'; + +/** Create a Logger whose output is captured into arrays for assertions. */ +function createTestLogger(): { logger: Logger; logs: string[]; errors: string[] } { + const logs: string[] = []; + const errors: string[] = []; + const logWriter: LogWriter = { + log(...data: any[]) { logs.push(data.map(String).join(' ')); }, + error(...data: any[]) { errors.push(data.map(String).join(' ')); }, + }; + const logger = new Logger(logWriter); + return { logger, logs, errors }; +} + +describe('detectUnknownSearchFlags', () => { + test('returns empty array when no flags are set', () => { + const values = {}; + assert.deepStrictEqual(detectUnknownSearchFlags(values, 'process-definition'), []); + }); + + test('returns empty array when only global flags are set', () => { + const values = { profile: 'dev', sortBy: 'Name', asc: true }; + assert.deepStrictEqual(detectUnknownSearchFlags(values, 'process-definition'), []); + }); + + test('returns empty array when valid resource flags are set', () => { + const values = { bpmnProcessId: 'my-process', name: 'My Process' }; + assert.deepStrictEqual(detectUnknownSearchFlags(values, 'process-definition'), []); + }); + + test('detects unknown flag for process-definitions', () => { + const values = { assignee: 'john', name: 'My Process' }; + const unknown = detectUnknownSearchFlags(values, 'process-definition'); + assert.deepStrictEqual(unknown, ['assignee']); + }); + + test('detects unknown flag for user-tasks', () => { + const values = { name: 'test', state: 'CREATED' }; + const unknown = detectUnknownSearchFlags(values, 'user-task'); + assert.deepStrictEqual(unknown, ['name']); + }); + + test('detects unknown flag for jobs', () => { + const values = { assignee: 'john', type: 'email' }; + const unknown = detectUnknownSearchFlags(values, 'jobs'); + assert.deepStrictEqual(unknown, ['assignee']); + }); + + test('detects unknown flag for variables', () => { + const values = { type: 'some-type', name: 'myVar' }; + const unknown = detectUnknownSearchFlags(values, 'variable'); + assert.deepStrictEqual(unknown, ['type']); + }); + + test('detects unknown flag for incidents', () => { + const values = { assignee: 'john', state: 'ACTIVE' }; + const unknown = detectUnknownSearchFlags(values, 'incident'); + assert.deepStrictEqual(unknown, ['assignee']); + }); + + test('ignores undefined and false values', () => { + const values = { assignee: undefined, errorType: false, state: 'ACTIVE' }; + const unknown = detectUnknownSearchFlags(values, 'jobs'); + assert.deepStrictEqual(unknown, []); + }); + + test('detects multiple unknown flags at once', () => { + const values = { assignee: 'john', errorType: 'IO', type: 'email' }; + const unknown = detectUnknownSearchFlags(values, 'jobs'); + // assignee and errorType are not valid for jobs + assert.ok(unknown.includes('assignee')); + assert.ok(unknown.includes('errorType')); + assert.strictEqual(unknown.length, 2); + }); + + test('handles pluralized resource names (process-definitions → process-definition)', () => { + const values = { name: 'test' }; + // 'process-definitions' is not a key; the function strips trailing 's' as fallback + const unknown = detectUnknownSearchFlags(values, 'process-definitions'); + assert.deepStrictEqual(unknown, []); + }); + + test('handles variables (pluralized → variable)', () => { + const values = { name: 'myVar', value: 'hello' }; + const unknown = detectUnknownSearchFlags(values, 'variables'); + assert.deepStrictEqual(unknown, []); + }); + + test('--id is valid for process-definition search', () => { + const values = { id: 'my-process' }; + const unknown = detectUnknownSearchFlags(values, 'process-definition'); + assert.deepStrictEqual(unknown, []); + }); + + test('--processDefinitionId is valid for process-definition search', () => { + const values = { processDefinitionId: 'my-process' }; + const unknown = detectUnknownSearchFlags(values, 'process-definition'); + assert.deepStrictEqual(unknown, []); + }); + + test('--id is valid for process-instance search', () => { + const values = { id: 'my-process' }; + const unknown = detectUnknownSearchFlags(values, 'process-instance'); + assert.deepStrictEqual(unknown, []); + }); + + test('--processDefinitionId is valid for incident search', () => { + const values = { processDefinitionId: 'my-process' }; + const unknown = detectUnknownSearchFlags(values, 'incident'); + assert.deepStrictEqual(unknown, []); + }); + + test('returns empty array for unknown resource', () => { + const values = { foo: 'bar' }; + assert.deepStrictEqual(detectUnknownSearchFlags(values, 'unknown-resource'), []); + }); + + test('truly unknown flags (not in any resource) are detected', () => { + const values = { fooBarBaz: 'test' }; + const unknown = detectUnknownSearchFlags(values, 'process-definition'); + assert.deepStrictEqual(unknown, ['fooBarBaz']); + }); +}); + +describe('GLOBAL_FLAGS', () => { + test('contains expected common flags', () => { + assert.ok(GLOBAL_FLAGS.has('profile')); + assert.ok(GLOBAL_FLAGS.has('sortBy')); + assert.ok(GLOBAL_FLAGS.has('asc')); + assert.ok(GLOBAL_FLAGS.has('desc')); + assert.ok(GLOBAL_FLAGS.has('help')); + assert.ok(GLOBAL_FLAGS.has('version')); + }); + + test('does not contain limit (limit is only valid for variable search)', () => { + assert.ok(!GLOBAL_FLAGS.has('limit')); + }); +}); + +describe('SEARCH_RESOURCE_FLAGS', () => { + test('process-definition includes all expected flags', () => { + const flags = SEARCH_RESOURCE_FLAGS['process-definition']; + assert.ok(flags.has('bpmnProcessId')); + assert.ok(flags.has('id')); + assert.ok(flags.has('processDefinitionId')); + assert.ok(flags.has('name')); + assert.ok(flags.has('key')); + assert.ok(flags.has('iid')); + assert.ok(flags.has('iname')); + }); + + test('process-instance includes all expected flags', () => { + const flags = SEARCH_RESOURCE_FLAGS['process-instance']; + assert.ok(flags.has('bpmnProcessId')); + assert.ok(flags.has('id')); + assert.ok(flags.has('processDefinitionId')); + assert.ok(flags.has('processDefinitionKey')); + assert.ok(flags.has('state')); + assert.ok(flags.has('key')); + assert.ok(flags.has('parentProcessInstanceKey')); + assert.ok(flags.has('iid')); + }); + + test('user-task includes all expected flags', () => { + const flags = SEARCH_RESOURCE_FLAGS['user-task']; + assert.ok(flags.has('state')); + assert.ok(flags.has('assignee')); + assert.ok(flags.has('processInstanceKey')); + assert.ok(flags.has('processDefinitionKey')); + assert.ok(flags.has('elementId')); + assert.ok(flags.has('iassignee')); + }); + + test('incident includes all expected flags', () => { + const flags = SEARCH_RESOURCE_FLAGS['incident']; + assert.ok(flags.has('state')); + assert.ok(flags.has('processInstanceKey')); + assert.ok(flags.has('processDefinitionKey')); + assert.ok(flags.has('bpmnProcessId')); + assert.ok(flags.has('id')); + assert.ok(flags.has('processDefinitionId')); + assert.ok(flags.has('errorType')); + assert.ok(flags.has('errorMessage')); + assert.ok(flags.has('ierrorMessage')); + assert.ok(flags.has('iid')); + }); + + test('jobs includes all expected flags', () => { + const flags = SEARCH_RESOURCE_FLAGS['jobs']; + assert.ok(flags.has('state')); + assert.ok(flags.has('type')); + assert.ok(flags.has('processInstanceKey')); + assert.ok(flags.has('processDefinitionKey')); + assert.ok(flags.has('itype')); + }); + + test('variable includes all expected flags', () => { + const flags = SEARCH_RESOURCE_FLAGS['variable']; + assert.ok(flags.has('name')); + assert.ok(flags.has('value')); + assert.ok(flags.has('processInstanceKey')); + assert.ok(flags.has('scopeKey')); + assert.ok(flags.has('fullValue')); + assert.ok(flags.has('iname')); + assert.ok(flags.has('ivalue')); + assert.ok(flags.has('limit')); + }); + + test('all resources have entries', () => { + const resources = ['process-definition', 'process-instance', 'user-task', 'incident', 'jobs', 'variable']; + for (const resource of resources) { + assert.ok(SEARCH_RESOURCE_FLAGS[resource], `Missing entry for ${resource}`); + assert.ok(SEARCH_RESOURCE_FLAGS[resource].size > 0, `Empty flags for ${resource}`); + } + }); +}); + +describe('logNoResults', () => { + test('prints 🕳️ message when no results found with filters', () => { + const { logger, logs } = createTestLogger(); + logNoResults(logger, 'process definitions', true); + assert.strictEqual(logs.length, 1); + assert.ok(logs[0].includes('🕳️')); + assert.ok(logs[0].includes('No process definitions found')); + }); + + test('prints no-filter hint when hasFilters is false', () => { + const { logger, logs } = createTestLogger(); + logNoResults(logger, 'user tasks', false); + assert.strictEqual(logs.length, 2); + assert.ok(logs[0].includes('🕳️')); + assert.ok(logs[1].includes('No filters were applied')); + assert.ok(logs[1].includes('c8ctl help search')); + }); + + test('does not print no-filter hint when hasFilters is true', () => { + const { logger, logs } = createTestLogger(); + logNoResults(logger, 'incidents', true); + assert.strictEqual(logs.length, 1); + }); + + test('mentions unknown flags when provided', () => { + const { logger, logs } = createTestLogger(); + logNoResults(logger, 'jobs', true, ['assignee', 'name']); + assert.strictEqual(logs.length, 1); + assert.ok(logs[0].includes('--assignee')); + assert.ok(logs[0].includes('--name')); + assert.ok(logs[0].includes('ignored unknown flag')); + }); + + test('does not mention unknown flags when array is empty', () => { + const { logger, logs } = createTestLogger(); + logNoResults(logger, 'variables', true, []); + assert.strictEqual(logs.length, 1); + assert.ok(!logs[0].includes('ignored unknown')); + }); + + test('combines unknown flags and no-filter hint', () => { + const { logger, logs } = createTestLogger(); + logNoResults(logger, 'process instances', false, ['fooBar']); + assert.strictEqual(logs.length, 2); + assert.ok(logs[0].includes('--fooBar')); + assert.ok(logs[1].includes('No filters were applied')); + }); +}); + +describe('logResultCount', () => { + test('prints found count', () => { + const { logger, logs } = createTestLogger(); + logResultCount(logger, 5, 'process definition(s)', true); + assert.strictEqual(logs.length, 1); + assert.ok(logs[0].includes('Found 5 process definition(s)')); + }); + + test('warns about API default page size when count equals 100 and no filters', () => { + const { logger, logs, errors } = createTestLogger(); + logResultCount(logger, 100, 'process definition(s)', false); + assert.strictEqual(logs.length, 1); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].includes('API default page size')); + assert.ok(errors[0].includes('add filters')); + }); + + test('warns about page size when count equals 100 with filters', () => { + const { logger, logs, errors } = createTestLogger(); + logResultCount(logger, 100, 'process instance(s)', true); + assert.strictEqual(logs.length, 1); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].includes('There may be more results')); + }); + + test('no warning when count is below 100', () => { + const { logger, logs, errors } = createTestLogger(); + logResultCount(logger, 42, 'incidents', false); + assert.strictEqual(logs.length, 1); + assert.strictEqual(errors.length, 0); + }); + + test('no warning when count is above 100', () => { + const { logger, logs, errors } = createTestLogger(); + logResultCount(logger, 150, 'variables', true); + assert.strictEqual(logs.length, 1); + assert.strictEqual(errors.length, 0); + }); +});