diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 66c7333..cecc604 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,6 +70,8 @@ jobs: - name: Run integration tests run: npm run test:integration + env: + CAMUNDA_VERSION: ${{ matrix.camunda }} - name: Stop Camunda if: always() diff --git a/src/commands/completion.ts b/src/commands/completion.ts index ec67cec..d2a3552 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -50,7 +50,7 @@ _c8ctl_completions() { local help_resources="list get create complete await search deploy run watch cancel resolve fail activate publish correlate upgrade downgrade init profiles profile plugin plugins" # Global flags - local flags="--help --version --profile --from --all --bpmnProcessId --id --processInstanceKey --processDefinitionKey --parentProcessInstanceKey --variables --state --assignee --type --correlationKey --timeToLive --maxJobsToActivate --timeout --worker --retries --errorMessage --baseUrl --clientId --clientSecret --audience --oAuthUrl --defaultTenantId --awaitCompletion --fetchVariables --requestTimeout --sortBy --asc --desc --limit --name --key --elementId --errorType --value --scopeKey --fullValue --userTask --ut --processDefinition --pd --iname --iid --iassignee --ierrorMessage --itype --ivalue" + local flags="--help --version --profile --from --all --bpmnProcessId --id --processInstanceKey --processDefinitionKey --parentProcessInstanceKey --variables --state --assignee --type --correlationKey --timeToLive --maxJobsToActivate --timeout --worker --retries --errorMessage --baseUrl --clientId --clientSecret --audience --oAuthUrl --defaultTenantId --awaitCompletion --fetchVariables --requestTimeout --sortBy --asc --desc --limit --between --dateField --name --key --elementId --errorType --value --scopeKey --fullValue --userTask --ut --processDefinition --pd --iname --iid --iassignee --ierrorMessage --itype --ivalue" case \${cword} in 1) @@ -237,6 +237,8 @@ _c8ctl() { '--asc[Sort in ascending order (default)]' '--desc[Sort in descending order]' '--limit[Maximum number of items to fetch]:number:' + '--between[Filter by date range (from..to)]:range:' + '--dateField[Date field to filter on with --between]:field:' '--name[Variable or resource name]:name:' '--key[Resource key]:key:' '--elementId[Element ID]:id:' @@ -576,6 +578,10 @@ complete -c c8ctl -l desc -d 'Sort in descending order' complete -c c8 -l desc -d 'Sort in descending order' complete -c c8ctl -l limit -d 'Maximum number of items to fetch' -r complete -c c8 -l limit -d 'Maximum number of items to fetch' -r +complete -c c8ctl -l between -d 'Filter by date range (from..to)' -r +complete -c c8 -l between -d 'Filter by date range (from..to)' -r +complete -c c8ctl -l dateField -d 'Date field to filter on with --between' -r +complete -c c8 -l dateField -d 'Date field to filter on with --between' -r complete -c c8ctl -l name -d 'Variable or resource name' -r complete -c c8 -l name -d 'Variable or resource name' -r complete -c c8ctl -l key -d 'Resource key' -r diff --git a/src/commands/help.ts b/src/commands/help.ts index 6dd2d94..1c0061f 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -95,6 +95,8 @@ Flags: --asc Sort in ascending order (default) --desc Sort in descending order --limit Maximum number of items to fetch (default: 1000000) + --between .. Filter by date range (use with 'list' or 'search'; short dates YYYY-MM-DD or ISO 8601) + --dateField Date field to filter on with --between (default depends on resource) --version, -v Show version --help, -h Show help @@ -257,6 +259,8 @@ Resources and their available flags: --id Filter by process definition ID (alias: --bpmnProcessId) --state Filter by state (ACTIVE, COMPLETED, etc.) --all List all instances (pagination) + --between .. Filter by date range (default field: startDate) + --dateField Date field for --between (startDate, endDate) --sortBy Sort by column (Key, Process ID, State, Version, Start Date, Tenant ID) --asc Sort in ascending order (default) --desc Sort in descending order @@ -275,6 +279,8 @@ Resources and their available flags: --state Filter by state (CREATED, COMPLETED, etc.) --assignee Filter by assignee --all List all tasks (pagination) + --between .. Filter by date range (default field: creationDate) + --dateField Date field for --between (creationDate, completionDate, followUpDate, dueDate) --sortBy Sort by column (Key, Name, State, Assignee, Created, Process Instance, Tenant ID) --asc Sort in ascending order (default) --desc Sort in descending order @@ -284,6 +290,7 @@ Resources and their available flags: incidents (inc) --state Filter by state (ACTIVE, RESOLVED, etc.) --processInstanceKey Filter by process instance + --between .. Filter by date range (field: creationTime) --sortBy Sort by column (Key, Type, Message, State, Created, Process Instance, Tenant ID) --asc Sort in ascending order (default) --desc Sort in descending order @@ -293,6 +300,8 @@ Resources and their available flags: jobs --state Filter by state (ACTIVATABLE, ACTIVATED, etc.) --type Filter by job type + --between .. Filter by date range (default field: creationTime) + --dateField Date field for --between (creationTime, lastUpdateTime) --sortBy Sort by column (Key, Type, State, Retries, Created, Process Instance, Tenant ID) --asc Sort in ascending order (default) --desc Sort in descending order @@ -308,13 +317,18 @@ Resources and their available flags: Examples: c8ctl list pi --state=ACTIVE + c8ctl list pi --between=2024-01-01..2024-12-31 + c8ctl list pi --between=2024-01-01T00:00:00Z..2024-06-30T23:59:59Z --dateField=endDate c8ctl list pi --sortBy=State c8ctl list pi --sortBy=State --desc c8ctl list ut --assignee=john.doe + c8ctl list ut --between=2024-01-01..2024-03-31 --dateField=dueDate c8ctl list ut --sortBy=Assignee c8ctl list inc --processInstanceKey=123456 + c8ctl list inc --between=2024-06-01..2024-06-30 c8ctl list inc --sortBy=Type --desc c8ctl list jobs --type=email-service + c8ctl list jobs --between=2024-01-01..2024-12-31 c8ctl list jobs --sortBy=Retries --asc c8ctl list profiles c8ctl list plugins @@ -534,6 +548,8 @@ Resources and their available flags: --state Filter by state (ACTIVE, COMPLETED, etc.) --key Filter by key --parentProcessInstanceKey Filter by parent process instance key + --between .. Filter by date range (default field: startDate) + --dateField Date field for --between (startDate, endDate) --sortBy Sort by column (Key, Process ID, State, Version, Tenant ID) --asc Sort in ascending order (default) --desc Sort in descending order @@ -557,6 +573,8 @@ Resources and their available flags: --processInstanceKey Filter by process instance key --processDefinitionKey Filter by process definition key --elementId Filter by element ID + --between .. Filter by date range (default field: creationDate) + --dateField Date field for --between (creationDate, completionDate, followUpDate, dueDate) --sortBy Sort by column (Key, Name, State, Assignee, Process Instance, Tenant ID) --asc Sort in ascending order (default) --desc Sort in descending order @@ -571,6 +589,7 @@ Resources and their available flags: --errorType Filter by error type --errorMessage Filter by error message --ierrorMessage Case-insensitive --errorMessage filter + --between .. Filter by date range (field: creationTime) --sortBy Sort by column (Key, Type, Message, State, Process Instance, Tenant ID) --asc Sort in ascending order (default) --desc Sort in descending order @@ -582,6 +601,8 @@ Resources and their available flags: --itype Case-insensitive --type filter --processInstanceKey Filter by process instance key --processDefinitionKey Filter by process definition key + --between .. Filter by date range (default field: creationTime) + --dateField Date field for --between (creationTime, lastUpdateTime) --sortBy Sort by column (Key, Type, State, Retries, Process Instance, Tenant ID) --asc Sort in ascending order (default) --desc Sort in descending order @@ -601,6 +622,14 @@ Resources and their available flags: --limit Maximum number of items to fetch (default: 1000000) --profile Use specific profile +Date Range Filter: + Use --between .. to filter results by a date range. + Dates can be short (YYYY-MM-DD) or full ISO 8601 datetimes. + Short dates: 'from' is expanded to T00:00:00.000Z, 'to' to T23:59:59.999Z. + Use --dateField to specify which date field to filter on (default depends on resource). + Example: --between=2024-01-01..2024-12-31 + Example: --between=2024-01-01T00:00:00Z..2024-06-30T23:59:59Z --dateField=endDate + Wildcard Search: String filters support wildcards: * (any chars) and ? (single char). Example: --name='*main*' matches all names containing "main". @@ -613,15 +642,20 @@ Case-Insensitive Search: Examples: c8ctl search pi --state=ACTIVE c8ctl search pi --bpmnProcessId=order-process + c8ctl search pi --between=2024-01-01..2024-12-31 + c8ctl search pi --between=2024-01-01..2024-06-30 --dateField=endDate c8ctl search pd --name='*main*' c8ctl search pd --iname='*order*' c8ctl search pd --sortBy=Name --desc c8ctl search ut --assignee=john.doe c8ctl search ut --iassignee=John + c8ctl search ut --between=2024-01-01..2024-03-31 --dateField=dueDate c8ctl search ut --sortBy=State --asc c8ctl search inc --state=ACTIVE --processInstanceKey=123456 + c8ctl search inc --between=2024-06-01..2024-06-30 c8ctl search jobs --type=email-service c8ctl search jobs --itype='*SERVICE*' + c8ctl search jobs --between=2024-01-01..2024-12-31 c8ctl search jobs --sortBy=Type --desc c8ctl search variables --name=orderId c8ctl search variables --value=12345 --fullValue diff --git a/src/commands/incidents.ts b/src/commands/incidents.ts index c44d644..89ec193 100644 --- a/src/commands/incidents.ts +++ b/src/commands/incidents.ts @@ -6,6 +6,7 @@ import { getLogger } from '../logger.ts'; import { sortTableData, type SortOrder } from '../logger.ts'; import { createClient, fetchAllPages } from '../client.ts'; import { resolveTenantId } from '../config.ts'; +import { parseBetween, buildDateFilter } from '../date-filter.ts'; /** * List incidents @@ -17,6 +18,7 @@ export async function listIncidents(options: { sortBy?: string; sortOrder?: SortOrder; limit?: number; + between?: string; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -37,6 +39,16 @@ export async function listIncidents(options: { filter.filter.processInstanceKey = options.processInstanceKey; } + if (options.between) { + const parsed = parseBetween(options.between); + if (parsed) { + filter.filter.creationTime = buildDateFilter(parsed.from, parsed.to); + } else { + logger.error('Invalid --between value. Expected format: .. (e.g. 2024-01-01..2024-12-31 or ISO 8601 datetimes)'); + process.exit(1); + } + } + const allItems = await fetchAllPages( (f, opts) => client.searchIncidents(f, opts), filter, diff --git a/src/commands/jobs.ts b/src/commands/jobs.ts index a3ba822..cf19ff0 100644 --- a/src/commands/jobs.ts +++ b/src/commands/jobs.ts @@ -6,6 +6,7 @@ import { getLogger } from '../logger.ts'; import { sortTableData, type SortOrder } from '../logger.ts'; import { createClient, fetchAllPages } from '../client.ts'; import { resolveTenantId } from '../config.ts'; +import { parseBetween, buildDateFilter } from '../date-filter.ts'; /** * List jobs @@ -17,6 +18,8 @@ export async function listJobs(options: { sortBy?: string; sortOrder?: SortOrder; limit?: number; + between?: string; + dateField?: string; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -37,6 +40,17 @@ export async function listJobs(options: { filter.filter.type = options.type; } + if (options.between) { + const parsed = parseBetween(options.between); + if (parsed) { + const field = options.dateField ?? 'creationTime'; + filter.filter[field] = buildDateFilter(parsed.from, parsed.to); + } else { + logger.error('Invalid --between value. Expected format: .. (e.g. 2024-01-01..2024-12-31 or ISO 8601 datetimes)'); + process.exit(1); + } + } + const allItems = await fetchAllPages( (f, opts) => client.searchJobs(f, opts), filter, diff --git a/src/commands/process-instances.ts b/src/commands/process-instances.ts index fefeef7..accedd6 100644 --- a/src/commands/process-instances.ts +++ b/src/commands/process-instances.ts @@ -6,6 +6,7 @@ import { getLogger } from '../logger.ts'; import { sortTableData, type SortOrder } from '../logger.ts'; import { createClient, fetchAllPages } from '../client.ts'; import { resolveTenantId } from '../config.ts'; +import { parseBetween, buildDateFilter } from '../date-filter.ts'; import type { ProcessInstanceCreationInstructionById } from '@camunda8/orchestration-cluster-api'; /** @@ -19,6 +20,8 @@ export async function listProcessInstances(options: { sortBy?: string; sortOrder?: SortOrder; limit?: number; + between?: string; + dateField?: string; }): Promise<{ items: Array>; total?: number } | undefined> { const logger = getLogger(); const client = createClient(options.profile); @@ -42,6 +45,17 @@ export async function listProcessInstances(options: { filter.filter.state = 'ACTIVE'; } + if (options.between) { + const parsed = parseBetween(options.between); + if (parsed) { + const field = options.dateField ?? 'startDate'; + filter.filter[field] = buildDateFilter(parsed.from, parsed.to); + } else { + logger.error('Invalid --between value. Expected format: .. (e.g. 2024-01-01..2024-12-31 or ISO 8601 datetimes)'); + process.exit(1); + } + } + const allItems = await fetchAllPages( (f, opts) => client.searchProcessInstances(f, opts), filter, diff --git a/src/commands/search.ts b/src/commands/search.ts index 9a9ce44..95dd008 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -5,6 +5,7 @@ import { getLogger, Logger, sortTableData, type SortOrder } from '../logger.ts'; import { createClient, fetchAllPages } from '../client.ts'; import { resolveTenantId } from '../config.ts'; +import { parseBetween, buildDateFilter } from '../date-filter.ts'; export type SearchResult = { items: Array>; total?: number }; @@ -242,6 +243,8 @@ export async function searchProcessInstances(options: { iProcessDefinitionId?: string; sortBy?: string; sortOrder?: SortOrder; + between?: string; + dateField?: string; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -268,6 +271,10 @@ export async function searchProcessInstances(options: { if (options.iProcessDefinitionId) { criteria.push(formatCriterion('Process Definition ID', options.iProcessDefinitionId, true)); } + if (options.between) { + const field = options.dateField ?? 'startDate'; + criteria.push(`'${field}' between "${options.between}"`); + } logSearchCriteria(logger, 'Process Instances', criteria); try { @@ -299,6 +306,17 @@ export async function searchProcessInstances(options: { filter.filter.parentProcessInstanceKey = options.parentProcessInstanceKey; } + if (options.between) { + const parsed = parseBetween(options.between); + if (parsed) { + const field = options.dateField ?? 'startDate'; + filter.filter[field] = buildDateFilter(parsed.from, parsed.to); + } else { + logger.error('Invalid --between value. Expected format: .. (e.g. 2024-01-01..2024-12-31 or ISO 8601 datetimes)'); + process.exit(1); + } + } + const result = await client.searchProcessInstances(filter, { consistency: { waitUpToMs: 0 } }); if (hasCiFilter && result.items) { @@ -343,6 +361,8 @@ export async function searchUserTasks(options: { iAssignee?: string; sortBy?: string; sortOrder?: SortOrder; + between?: string; + dateField?: string; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -369,6 +389,10 @@ export async function searchUserTasks(options: { if (options.iAssignee) { criteria.push(formatCriterion('assignee', options.iAssignee, true)); } + if (options.between) { + const field = options.dateField ?? 'creationDate'; + criteria.push(`'${field}' between "${options.between}"`); + } logSearchCriteria(logger, 'User Tasks', criteria); try { @@ -400,6 +424,17 @@ export async function searchUserTasks(options: { filter.filter.elementId = options.elementId; } + if (options.between) { + const parsed = parseBetween(options.between); + if (parsed) { + const field = options.dateField ?? 'creationDate'; + filter.filter[field] = buildDateFilter(parsed.from, parsed.to); + } else { + logger.error('Invalid --between value. Expected format: .. (e.g. 2024-01-01..2024-12-31 or ISO 8601 datetimes)'); + process.exit(1); + } + } + const result = await client.searchUserTasks(filter, { consistency: { waitUpToMs: 0 } }); if (hasCiFilter && result.items) { @@ -447,6 +482,7 @@ export async function searchIncidents(options: { iProcessDefinitionId?: string; sortBy?: string; sortOrder?: SortOrder; + between?: string; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -481,6 +517,9 @@ export async function searchIncidents(options: { if (options.iProcessDefinitionId) { criteria.push(formatCriterion('Process Definition ID', options.iProcessDefinitionId, true)); } + if (options.between) { + criteria.push(`'creationTime' between "${options.between}"`); + } logSearchCriteria(logger, 'Incidents', criteria); try { @@ -516,6 +555,16 @@ export async function searchIncidents(options: { filter.filter.processDefinitionId = toStringFilter(options.processDefinitionId); } + if (options.between) { + const parsed = parseBetween(options.between); + if (parsed) { + filter.filter.creationTime = buildDateFilter(parsed.from, parsed.to); + } else { + logger.error('Invalid --between value. Expected format: .. (e.g. 2024-01-01..2024-12-31 or ISO 8601 datetimes)'); + process.exit(1); + } + } + const result = await client.searchIncidents(filter, { consistency: { waitUpToMs: 0 } }); if (hasCiFilter && result.items) { @@ -562,6 +611,8 @@ export async function searchJobs(options: { iType?: string; sortBy?: string; sortOrder?: SortOrder; + between?: string; + dateField?: string; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -585,6 +636,10 @@ export async function searchJobs(options: { if (options.iType) { criteria.push(formatCriterion('type', options.iType, true)); } + if (options.between) { + const field = options.dateField ?? 'creationTime'; + criteria.push(`'${field}' between "${options.between}"`); + } logSearchCriteria(logger, 'Jobs', criteria); try { @@ -612,6 +667,17 @@ export async function searchJobs(options: { filter.filter.processDefinitionKey = options.processDefinitionKey; } + if (options.between) { + const parsed = parseBetween(options.between); + if (parsed) { + const field = options.dateField ?? 'creationTime'; + filter.filter[field] = buildDateFilter(parsed.from, parsed.to); + } else { + logger.error('Invalid --between value. Expected format: .. (e.g. 2024-01-01..2024-12-31 or ISO 8601 datetimes)'); + process.exit(1); + } + } + const result = await client.searchJobs(filter, { consistency: { waitUpToMs: 0 } }); if (hasCiFilter && result.items) { diff --git a/src/commands/user-tasks.ts b/src/commands/user-tasks.ts index fd4c392..2faa08c 100644 --- a/src/commands/user-tasks.ts +++ b/src/commands/user-tasks.ts @@ -6,6 +6,7 @@ import { getLogger } from '../logger.ts'; import { sortTableData, type SortOrder } from '../logger.ts'; import { createClient, fetchAllPages } from '../client.ts'; import { resolveTenantId } from '../config.ts'; +import { parseBetween, buildDateFilter } from '../date-filter.ts'; /** * List user tasks @@ -18,6 +19,8 @@ export async function listUserTasks(options: { sortBy?: string; sortOrder?: SortOrder; limit?: number; + between?: string; + dateField?: string; }): Promise { const logger = getLogger(); const client = createClient(options.profile); @@ -41,6 +44,17 @@ export async function listUserTasks(options: { filter.filter.assignee = options.assignee; } + if (options.between) { + const parsed = parseBetween(options.between); + if (parsed) { + const field = options.dateField ?? 'creationDate'; + filter.filter[field] = buildDateFilter(parsed.from, parsed.to); + } else { + logger.error('Invalid --between value. Expected format: .. (e.g. 2024-01-01..2024-12-31 or ISO 8601 datetimes)'); + process.exit(1); + } + } + const allItems = await fetchAllPages( (f, opts) => client.searchUserTasks(f, opts), filter, diff --git a/src/date-filter.ts b/src/date-filter.ts new file mode 100644 index 0000000..1fbb389 --- /dev/null +++ b/src/date-filter.ts @@ -0,0 +1,58 @@ +/** + * Date range filter utilities for --between .. flag. + * + * Supports ISO 8601 datetime strings and short date strings (YYYY-MM-DD). + * Short dates are expanded: 'from' gets T00:00:00.000Z, 'to' gets T23:59:59.999Z. + */ + +/** + * Parse a `--between` value of the form `..`. + * Returns `{ from, to }` as ISO 8601 strings, or null on parse failure. + */ +export function parseBetween(value: string): { from: string; to: string } | null { + const separatorIndex = value.indexOf('..'); + if (separatorIndex < 0) return null; + + const rawFrom = value.slice(0, separatorIndex).trim(); + const rawTo = value.slice(separatorIndex + 2).trim(); + if (!rawFrom || !rawTo) return null; + + const from = expandDate(rawFrom, 'start'); + const to = expandDate(rawTo, 'end'); + if (!from || !to) return null; + + return { from, to }; +} + +/** + * Build an AdvancedDateTimeFilter object for a `--between` range. + * Returns `{ $gte: from, $lte: to }` as expected by the Camunda REST API. + */ +export function buildDateFilter(from: string, to: string): { $gte: string; $lte: string } { + return { $gte: from, $lte: to }; +} + +/** + * Expand a date string to a full ISO 8601 datetime string. + * If it already contains a 'T', it is treated as a full datetime string. + * Short dates (YYYY-MM-DD) are expanded: + * - 'start' boundary: T00:00:00.000Z + * - 'end' boundary: T23:59:59.999Z + */ +function expandDate(value: string, boundary: 'start' | 'end'): string | null { + if (value.includes('T')) { + // Validate as a datetime + const d = new Date(value); + if (isNaN(d.getTime())) return null; + return value; + } + + // Expect YYYY-MM-DD + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; + const d = new Date(value + 'T00:00:00.000Z'); + if (isNaN(d.getTime())) return null; + + return boundary === 'start' + ? `${value}T00:00:00.000Z` + : `${value}T23:59:59.999Z`; +} diff --git a/src/index.ts b/src/index.ts index cf8104c..745d0ca 100755 --- a/src/index.ts +++ b/src/index.ts @@ -123,6 +123,8 @@ function parseCliArgs() { asc: { type: 'boolean' }, desc: { type: 'boolean' }, limit: { type: 'string' }, + between: { type: 'string' }, + dateField: { type: 'string' }, }, allowPositionals: true, strict: false, @@ -349,6 +351,8 @@ async function main() { sortBy: values.sortBy as string | undefined, sortOrder, limit, + between: values.between as string | undefined, + dateField: values.dateField as string | undefined, }); return; } @@ -440,6 +444,8 @@ async function main() { sortBy: values.sortBy as string | undefined, sortOrder, limit, + between: values.between as string | undefined, + dateField: values.dateField as string | undefined, }); return; } @@ -465,6 +471,7 @@ async function main() { sortBy: values.sortBy as string | undefined, sortOrder, limit, + between: values.between as string | undefined, }); return; } @@ -500,6 +507,8 @@ async function main() { sortBy: values.sortBy as string | undefined, sortOrder, limit, + between: values.between as string | undefined, + dateField: values.dateField as string | undefined, }); return; } @@ -684,6 +693,8 @@ async function main() { iProcessDefinitionId: values.iid as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + between: values.between as string | undefined, + dateField: values.dateField as string | undefined, }); return; } @@ -699,6 +710,8 @@ async function main() { iAssignee: values.iassignee as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + between: values.between as string | undefined, + dateField: values.dateField as string | undefined, }); return; } @@ -716,6 +729,7 @@ async function main() { iProcessDefinitionId: values.iid as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + between: values.between as string | undefined, }); return; } @@ -730,6 +744,8 @@ async function main() { iType: values.itype as string | undefined, sortBy: values.sortBy as string | undefined, sortOrder, + between: values.between as string | undefined, + dateField: values.dateField as string | undefined, }); return; } diff --git a/tests/integration/process-instances.test.ts b/tests/integration/process-instances.test.ts index 7567a5f..411942f 100644 --- a/tests/integration/process-instances.test.ts +++ b/tests/integration/process-instances.test.ts @@ -13,10 +13,16 @@ import { createProcessInstance, listProcessInstances } from '../../src/commands/process-instances.ts'; +import { todayRange } from '../utils/date-helpers.ts'; +import { pollUntil } from '../utils/polling.ts'; import { existsSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { getUserDataDir } from '../../src/config.ts'; +// Polling configuration for Elasticsearch consistency +const POLL_TIMEOUT_MS = 30000; +const POLL_INTERVAL_MS = 1000; + describe('Process Instance Integration Tests (requires Camunda 8 at localhost:8080)', () => { beforeEach(() => { // Clear session state before each test to ensure clean tenant resolution @@ -175,4 +181,34 @@ describe('Process Instance Integration Tests (requires Camunda 8 at localhost:80 assert.ok(outputWithAlias.includes('completed'), 'Output with await alias should indicate process completed'); assert.ok(outputWithAlias.includes('variables'), 'Output with await alias should contain variables'); }); + + test('listProcessInstances with --between spanning today finds recently created instance', async () => { + await deploy(['tests/fixtures/simple.bpmn'], {}); + await createProcessInstance({ processDefinitionId: 'simple-process' }); + + const found = await pollUntil(async () => { + const result = await listProcessInstances({ + processDefinitionId: 'simple-process', + state: 'COMPLETED', + between: todayRange(), + }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + assert.ok(found, '--between spanning today should find recently completed process instances in list'); + }); + + test('listProcessInstances with --between in far past returns no instances', async () => { + await deploy(['tests/fixtures/simple.bpmn'], {}); + + // Use a date range well before any current test run + const result = await listProcessInstances({ + processDefinitionId: 'simple-process', + state: 'COMPLETED', + between: '2000-01-01..2000-01-02', + }); + + assert.ok(result, 'Result should be returned even when empty'); + assert.strictEqual(result!.items.length, 0, '--between with past date range should return no instances'); + }); }); diff --git a/tests/integration/search.test.ts b/tests/integration/search.test.ts index 0d0c870..83a987a 100644 --- a/tests/integration/search.test.ts +++ b/tests/integration/search.test.ts @@ -20,6 +20,7 @@ import { searchVariables, } from '../../src/commands/search.ts'; import { pollUntil } from '../utils/polling.ts'; +import { todayRange } from '../utils/date-helpers.ts'; import { existsSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { getUserDataDir } from '../../src/config.ts'; @@ -528,4 +529,205 @@ describe('Search Command Integration Tests (requires Camunda 8 at localhost:8080 assert.ok(!result?.items || result.items.length === 0, '--iName="nonexistent-process-name" should return no results'); }); + + // ── Date Range Filter Tests (--between) ────────────────────────────── + + test('searchProcessInstances with --between spanning today finds recently created instance', async () => { + await deploy(['tests/fixtures/simple.bpmn'], {}); + await createProcessInstance({ processDefinitionId: 'simple-process' }); + + const found = await pollUntil(async () => { + const result = await searchProcessInstances({ + processDefinitionId: 'simple-process', + state: 'COMPLETED', + between: todayRange(), + }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + assert.ok(found, '--between spanning today should find recently completed process instances'); + }); + + test('searchProcessInstances with --between and explicit --dateField=startDate finds instance', async () => { + await deploy(['tests/fixtures/simple.bpmn'], {}); + await createProcessInstance({ processDefinitionId: 'simple-process' }); + + const found = await pollUntil(async () => { + const result = await searchProcessInstances({ + processDefinitionId: 'simple-process', + between: todayRange(), + dateField: 'startDate', + }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + assert.ok(found, '--between with --dateField=startDate should find recently started process instances'); + }); + + test('searchUserTasks with --between spanning today finds recently created task', async () => { + await deploy(['tests/fixtures/list-pis'], {}); + await createProcessInstance({ processDefinitionId: 'Process_0t60ay7' }); + + const found = await pollUntil(async () => { + const result = await searchUserTasks({ + state: 'CREATED', + between: todayRange(), + }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + assert.ok(found, '--between spanning today should find recently created user tasks'); + }); + + test('searchIncidents with --between spanning today finds recently created incident', async () => { + await deploy(['tests/fixtures/simple-will-create-incident.bpmn'], {}); + const pi = await createProcessInstance({ processDefinitionId: 'Process_0yyrstd' }); + const piKey = String(pi!.processInstanceKey); + + // Wait for the job and fail it to produce an incident; filter by processInstanceKey to avoid + // picking up jobs from previous tests that may still appear as CREATED in the search index + let jobKey: string | undefined; + const jobFound = await pollUntil(async () => { + const result = await searchJobs({ type: 'unhandled-job-type', state: 'CREATED', processInstanceKey: piKey }); + if (result?.items && result.items.length > 0) { + const job = result.items.find((j: any) => (j.state || '').toUpperCase() === 'CREATED') as any; + if (job) { + jobKey = String(job.jobKey || job.key); + return true; + } + } + return false; + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + assert.ok(jobFound && jobKey, 'Job should exist before failing'); + + await failJob(jobKey!, { retries: 0, errorMessage: 'Intentional failure for between test' }); + + const found = await pollUntil(async () => { + const result = await searchIncidents({ + state: 'ACTIVE', + between: todayRange(), + }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + assert.ok(found, '--between spanning today should find recently created incidents'); + }); + + // Jobs --between tests require Camunda 8.9+ because `creationTime`/`lastUpdateTime` job search + // filter fields are only available in 8.9+ (see assets/c8/rest-api/jobs.yaml). + // Skip them when running against 8.8, using the CAMUNDA_VERSION env var set by the GH Actions matrix. + const camundaVersion = process.env.CAMUNDA_VERSION; + const isCamunda89Plus = camundaVersion !== '8.8'; + const jobsBetweenSkip = isCamunda89Plus + ? false + : `creationTime job filter requires Camunda 8.9+ (CAMUNDA_VERSION=${camundaVersion ?? 'unset'})`; + + test('list user-tasks --between via CLI does not error', async () => { + await deploy(['tests/fixtures/list-pis'], {}); + await createProcessInstance({ processDefinitionId: 'Process_0t60ay7' }); + + // Wait for the task to be indexed + await pollUntil(async () => { + const result = await searchUserTasks({ state: 'CREATED' }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + const { execSync } = await import('node:child_process'); + const output = execSync( + `node --no-warnings src/index.ts list ut --between=${todayRange()} --all`, + { encoding: 'utf8', cwd: process.cwd() } + ); + + assert.ok(typeof output === 'string', 'CLI should produce string output'); + }); + + test('list incidents --between via CLI does not error', async () => { + await deploy(['tests/fixtures/simple-will-create-incident.bpmn'], {}); + const pi = await createProcessInstance({ processDefinitionId: 'Process_0yyrstd' }); + const piKey = String(pi!.processInstanceKey); + + // Wait for a job and fail it to produce an incident; filter by processInstanceKey to avoid + // picking up jobs from previous tests that may still appear as CREATED in the search index + let jobKey: string | undefined; + await pollUntil(async () => { + const result = await searchJobs({ type: 'unhandled-job-type', state: 'CREATED', processInstanceKey: piKey }); + if (result?.items && result.items.length > 0) { + const job = result.items.find((j: any) => (j.state || '').toUpperCase() === 'CREATED') as any; + if (job) { jobKey = String(job.jobKey || job.key); return true; } + } + return false; + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + await failJob(jobKey!, { retries: 0, errorMessage: 'Intentional failure for list between test' }); + + // Wait for the incident to be indexed + await pollUntil(async () => { + const result = await searchIncidents({ state: 'ACTIVE' }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + const { execSync } = await import('node:child_process'); + const output = execSync( + `node --no-warnings src/index.ts list inc --between=${todayRange()}`, + { encoding: 'utf8', cwd: process.cwd() } + ); + + assert.ok(typeof output === 'string', 'CLI should produce string output'); + }); + + test('searchJobs with --between spanning today finds recently created job', + { skip: jobsBetweenSkip }, + async () => { + await deploy(['tests/fixtures/simple-service-task.bpmn'], {}); + await createProcessInstance({ processDefinitionId: 'Process_18glkb3' }); + + const found = await pollUntil(async () => { + const result = await searchJobs({ + type: 'n00b', + state: 'CREATED', + between: todayRange(), + }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + assert.ok(found, '--between spanning today should find recently created jobs'); + }); + + test('searchJobs with --between and explicit --dateField=creationTime finds recently created job', + { skip: jobsBetweenSkip }, + async () => { + await deploy(['tests/fixtures/simple-service-task.bpmn'], {}); + await createProcessInstance({ processDefinitionId: 'Process_18glkb3' }); + + const found = await pollUntil(async () => { + const result = await searchJobs({ + type: 'n00b', + between: todayRange(), + dateField: 'creationTime', + }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + assert.ok(found, '--between with --dateField=creationTime should find recently created jobs'); + }); + + test('list jobs --between via CLI does not error', + { skip: jobsBetweenSkip }, + async () => { + await deploy(['tests/fixtures/simple-service-task.bpmn'], {}); + await createProcessInstance({ processDefinitionId: 'Process_18glkb3' }); + + // Wait for the job to be indexed + await pollUntil(async () => { + const result = await searchJobs({ type: 'n00b', state: 'CREATED' }); + return !!(result?.items && result.items.length > 0); + }, POLL_TIMEOUT_MS, POLL_INTERVAL_MS); + + const { execSync } = await import('node:child_process'); + const output = execSync( + `node --no-warnings src/index.ts list jobs --between=${todayRange()}`, + { encoding: 'utf8', cwd: process.cwd() } + ); + + assert.ok(typeof output === 'string', 'CLI should produce string output'); + }); }); diff --git a/tests/unit/date-filter.test.ts b/tests/unit/date-filter.test.ts new file mode 100644 index 0000000..e45cf91 --- /dev/null +++ b/tests/unit/date-filter.test.ts @@ -0,0 +1,72 @@ +/** + * Unit tests for date-filter utilities + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { parseBetween, buildDateFilter } from '../../src/date-filter.ts'; + +describe('parseBetween', () => { + test('parses full ISO 8601 datetime range', () => { + const result = parseBetween('2024-01-01T00:00:00Z..2024-12-31T23:59:59Z'); + assert.ok(result); + assert.strictEqual(result.from, '2024-01-01T00:00:00Z'); + assert.strictEqual(result.to, '2024-12-31T23:59:59Z'); + }); + + test('expands short date from to start of day', () => { + const result = parseBetween('2024-01-01..2024-03-31'); + assert.ok(result); + assert.strictEqual(result.from, '2024-01-01T00:00:00.000Z'); + assert.strictEqual(result.to, '2024-03-31T23:59:59.999Z'); + }); + + test('expands mixed short and full datetime', () => { + const result = parseBetween('2024-01-01..2024-03-31T12:00:00Z'); + assert.ok(result); + assert.strictEqual(result.from, '2024-01-01T00:00:00.000Z'); + assert.strictEqual(result.to, '2024-03-31T12:00:00Z'); + }); + + test('returns null when separator is missing', () => { + const result = parseBetween('2024-01-01 2024-12-31'); + assert.strictEqual(result, null); + }); + + test('returns null when from part is empty', () => { + const result = parseBetween('..2024-12-31'); + assert.strictEqual(result, null); + }); + + test('returns null when to part is empty', () => { + const result = parseBetween('2024-01-01..'); + assert.strictEqual(result, null); + }); + + test('returns null for invalid date strings', () => { + const result = parseBetween('not-a-date..2024-12-31'); + assert.strictEqual(result, null); + }); + + test('returns null for invalid ISO datetime', () => { + const result = parseBetween('2024-13-01T00:00:00Z..2024-12-31T23:59:59Z'); + assert.strictEqual(result, null); + }); + + test('handles whitespace around separator', () => { + const result = parseBetween('2024-01-01 .. 2024-12-31'); + assert.ok(result); + assert.strictEqual(result.from, '2024-01-01T00:00:00.000Z'); + assert.strictEqual(result.to, '2024-12-31T23:59:59.999Z'); + }); +}); + +describe('buildDateFilter', () => { + test('builds $gte/$lte filter object', () => { + const filter = buildDateFilter('2024-01-01T00:00:00Z', '2024-12-31T23:59:59Z'); + assert.deepStrictEqual(filter, { + $gte: '2024-01-01T00:00:00Z', + $lte: '2024-12-31T23:59:59Z', + }); + }); +}); diff --git a/tests/unit/help.test.ts b/tests/unit/help.test.ts index aeaaf5b..4bb8142 100644 --- a/tests/unit/help.test.ts +++ b/tests/unit/help.test.ts @@ -84,6 +84,10 @@ describe('Help Module', () => { assert.ok(output.includes('--ierrorMessage')); assert.ok(output.includes('--itype')); assert.ok(output.includes('--ivalue')); + + // Check for date range filter flags + assert.ok(output.includes('--between')); + assert.ok(output.includes('--dateField')); }); test('showVerbResources shows resources for list', () => { @@ -239,6 +243,8 @@ describe('Help Module', () => { assert.ok(output.includes('plugins')); assert.ok(output.includes('⚠'), 'list pi help should mention the incident indicator symbol'); assert.ok(output.includes('incident'), 'list pi help should explain the indicator is for incidents'); + assert.ok(output.includes('--between'), 'list help should include --between flag'); + assert.ok(output.includes('--dateField'), 'list help should include --dateField flag'); }); test('showCommandHelp shows get help with resources and flags', () => { @@ -298,6 +304,9 @@ describe('Help Module', () => { assert.ok(output.includes('--limit')); assert.ok(output.includes('Wildcard Search')); assert.ok(output.includes('Case-Insensitive Search')); + assert.ok(output.includes('Date Range Filter'), 'search help should include date range filter section'); + assert.ok(output.includes('--between'), 'search help should include --between flag'); + assert.ok(output.includes('--dateField'), 'search help should include --dateField flag'); }); test('showCommandHelp shows deploy help', () => { diff --git a/tests/utils/date-helpers.ts b/tests/utils/date-helpers.ts new file mode 100644 index 0000000..11a030a --- /dev/null +++ b/tests/utils/date-helpers.ts @@ -0,0 +1,16 @@ +/** + * Date helper utilities for integration tests. + */ + +/** Milliseconds in one calendar day */ +export const MS_PER_DAY = 86_400_000; + +/** + * Returns a `YYYY-MM-DD..YYYY-MM-DD` date range spanning yesterday to tomorrow, + * so any resource created during the current test run falls inside the window. + */ +export function todayRange(): string { + const yesterday = new Date(Date.now() - MS_PER_DAY).toISOString().slice(0, 10); + const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().slice(0, 10); + return `${yesterday}..${tomorrow}`; +}