From c40ba099a8bda5d25dfceb5238c3b5f86d04a9d0 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sat, 13 Jun 2026 15:08:55 +0100 Subject: [PATCH 1/7] add auto increment timestamp generator --- ...lease-prep-combinatorial-grid-workflows.md | 41 +++- .../040-test-data/018-Schema-Definition.md | 7 +- .../domain/000-domain-test-data.md | 8 +- .../040-test-data/domain/025-autoIncrement.md | 48 +++++ package.json | 5 +- .../schema/test-data-command-catalog.test.js | 3 + .../tests/shared/help-model-builder.test.js | 9 + .../domain-command-help-metadata.test.js | 6 + .../domain/domainTestDataGenerator.js | 3 +- .../n-wise/pairwiseTestDataGenerator.js | 13 +- .../rulesBasedDataGenerator.js | 7 +- .../js/data_generation/testDataGenerator.js | 3 + .../js/domain/auto-increment-timestamp.js | 187 ++++++++++++++++++ ...ustom-autoincrement-keyword-definitions.js | 65 ++++++ .../js/domain/domain-keyword-definitions.js | 2 + packages/core/js/domain/domain-keywords.js | 2 + packages/core/package.json | 3 + ...utoincrement-timestamp-integration.test.js | 49 +++++ ...omain-autoincrement-timestamp-exec.test.js | 57 ++++++ .../unit/domain/domainKeywords.test.js | 2 + playwright-api.config.js | 2 +- pnpm-lock.yaml | 38 ++++ scripts/run-playwright-api-tests.mjs | 32 +++ scripts/run-storybook-tests.mjs | 2 +- vitest.config.mjs | 7 +- 25 files changed, 576 insertions(+), 25 deletions(-) create mode 100644 docs-src/docs/040-test-data/domain/025-autoIncrement.md create mode 100644 packages/core/js/domain/auto-increment-timestamp.js create mode 100644 packages/core/js/domain/domain-custom-autoincrement-keyword-definitions.js create mode 100644 packages/core/src/tests/data_generation/autoincrement-timestamp-integration.test.js create mode 100644 packages/core/src/tests/data_generation/unit/domain/domain-autoincrement-timestamp-exec.test.js create mode 100644 scripts/run-playwright-api-tests.mjs diff --git a/docs-src/blog/2026-06-12-release-prep-combinatorial-grid-workflows.md b/docs-src/blog/2026-06-12-release-prep-combinatorial-grid-workflows.md index 36b58fac..5c539f01 100644 --- a/docs-src/blog/2026-06-12-release-prep-combinatorial-grid-workflows.md +++ b/docs-src/blog/2026-06-12-release-prep-combinatorial-grid-workflows.md @@ -1,6 +1,6 @@ --- slug: release-prep-combinatorial-grid-workflows -title: "Release Prep: Stronger Combinatorial Generation and Faster Grid Workflows" +title: 'Release Prep: Stronger Combinatorial Generation and Faster Grid Workflows' authors: [alan] tags: [release, feature, combinatorial, schema, import, export, ux] date: 2026-06-12T10:00 @@ -12,7 +12,30 @@ This release adds broader combinatorial generation, schema authoring improvement -## 1. N-wise combinatorial generation, not just pairwise +## 1. Auto-increment timestamps for deterministic event streams + +You can now generate timestamps that move forward one row at a time instead of relying on purely random dates. + +Example: + +```text +CreatedAt +autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=1, type="seconds") +``` + +That produces: + +- row 1: `2026-06-12T12:39:23Z` +- row 2: `2026-06-12T12:39:24Z` +- row 3: `2026-06-12T12:39:25Z` + +This is useful for audit logs, event streams, ordered API records, and any test data where time should progress predictably across generated rows. + +Docs: + +- [autoIncrement Domain](/docs/test-data/domain/autoIncrement) + +## 2. N-wise combinatorial generation, not just pairwise The biggest addition is that combinatorial generation now goes beyond pairwise. @@ -42,7 +65,7 @@ Docs: ![N-wise combinations dialog](/img/release-198/n-wise-generation.png) -## 2. Schema constraints with PICT-style `IF ... THEN ...` +## 3. Schema constraints with PICT-style `IF ... THEN ...` Schema constraints make generated combinations more realistic by filtering out invalid rows. @@ -71,7 +94,7 @@ Docs: - [Schema Definition](/docs/test-data/Schema-Definition) -## 3. Grid to Enum Schema for turning existing tables into generators +## 4. Grid to Enum Schema for turning existing tables into generators If you already have representative data in the main grid, you can now turn that grid into an enum schema automatically. @@ -103,7 +126,7 @@ Docs: ![Grid to enum schema in the app](/img/release-198/grid-to-enum-schema.png) -## 4. PICT-style inline enum definitions such as `Name: values` +## 5. PICT-style inline enum definitions such as `Name: values` Schema text now fits more naturally with compact PICT-style authoring. @@ -128,7 +151,7 @@ Docs: - [Schema Definition](/docs/test-data/Schema-Definition) -## 5. Import trimming controls for cleaner amend and import workflows +## 6. Import trimming controls for cleaner amend and import workflows Imported files and clipboard data can now be normalized during import. @@ -151,7 +174,7 @@ Docs: ![Import trim settings](/img/release-198/import-trim-settings.png) -## 6. File export settings for line endings and BOM +## 7. File export settings for line endings and BOM Downloads now support file transport settings without changing the preview text shown in the browser. @@ -168,7 +191,7 @@ Docs: ![Download encoding settings](/img/release-198/export-encoding-settings.png) -## 7. Right-click context menu in the main data grid +## 8. Right-click context menu in the main data grid The editable grid now has a right-click context menu for common grid actions. @@ -178,7 +201,7 @@ Docs: - [Data Grid Editable](/docs/test-data/data-grid-editable) -## 8. Always-visible total row counts in the data grid +## 9. Always-visible total row counts in the data grid The main grid now shows total row counts, and filtered views also show how many rows remain visible. diff --git a/docs-src/docs/040-test-data/018-Schema-Definition.md b/docs-src/docs/040-test-data/018-Schema-Definition.md index 96accc52..3bae5b95 100644 --- a/docs-src/docs/040-test-data/018-Schema-Definition.md +++ b/docs-src/docs/040-test-data/018-Schema-Definition.md @@ -91,7 +91,12 @@ Customer Name person.fullName ``` -This generates names such as `Alice Smith`. +```text +CreatedAt +autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=1, type="seconds") +``` + +This generates names such as `Alice Smith`, or deterministic timestamps when you need time-ordered rows. ## Comments and blank lines diff --git a/docs-src/docs/040-test-data/domain/000-domain-test-data.md b/docs-src/docs/040-test-data/domain/000-domain-test-data.md index 6f98eb74..3d4b7c59 100644 --- a/docs-src/docs/040-test-data/domain/000-domain-test-data.md +++ b/docs-src/docs/040-test-data/domain/000-domain-test-data.md @@ -39,6 +39,11 @@ Date date.between(from=1577836800000, to=1659312000000) ``` +```txt +CreatedAt +autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=1, type="seconds") +``` + ```txt IBAN finance.iban(formatted=true, countryCode="GB") @@ -62,6 +67,7 @@ For faker helper templates and utility functions, use faker helpers: ## Domains +- [autoIncrement](/docs/test-data/domain/autoIncrement) - [airline](/docs/test-data/domain/airline) - [animal](/docs/test-data/domain/animal) - [book](/docs/test-data/domain/book) @@ -88,4 +94,4 @@ For faker helper templates and utility functions, use faker helpers: - [string](/docs/test-data/domain/string) - [system](/docs/test-data/domain/system) - [vehicle](/docs/test-data/domain/vehicle) -- [word](/docs/test-data/domain/word) \ No newline at end of file +- [word](/docs/test-data/domain/word) diff --git a/docs-src/docs/040-test-data/domain/025-autoIncrement.md b/docs-src/docs/040-test-data/domain/025-autoIncrement.md new file mode 100644 index 00000000..8832619c --- /dev/null +++ b/docs-src/docs/040-test-data/domain/025-autoIncrement.md @@ -0,0 +1,48 @@ +--- +sidebar_position: 25 +title: "autoIncrement Domain" +description: "Domain keyword reference for autoIncrement." +--- + +# autoIncrement Domain + +The `autoIncrement` domain provides deterministic values that move forward for each generated row. + +## Methods + +### `autoIncrement.timestamp` + +Generates a timestamp that starts from a fixed point and increments by the configured amount for each generated row. + +- Canonical: `awd.domain.autoIncrement.timestamp` + +| Arg | Type | Required | Description | +| --- | --- | --- | --- | +| `start` | `string\|number` | no | Starting timestamp. Defaults to the generation run start time. Strings may be ISO-8601, a date-fns formatted value when `inputFormat` is provided, or a permissive natural-language date parsed with chrono-node. | +| `step` | `number` | no | Amount added for each generated row. Defaults to `1`. | +| `type` | `string` | no | Unit applied to `step` for each row. Supports `milliseconds`, `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, or `years`. Defaults to `seconds`. | +| `outputFormat` | `string` | no | Output format. Defaults to ISO-8601 without milliseconds. Use `iso8601` for the default behaviour or a date-fns format string. | +| `inputFormat` | `string` | no | Optional date-fns parse format used only for `start` when that value is not ISO-8601. | + +Examples: + +```txt +autoIncrement.timestamp() +``` + +```txt +autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=1, type="seconds") +``` + +```txt +autoIncrement.timestamp(start="20/03/1969", step=1, type="days", outputFormat="yyyy-MM-dd") +``` + +```txt +autoIncrement.timestamp(start="12-06-2026 12:39:23", step=15, type="minutes", outputFormat="yyyy-MM-dd HH:mm:ss", inputFormat="dd-MM-yyyy HH:mm:ss") +``` + +Example return values: +- `2026-06-12T12:39:23Z` +- `2026-06-12T12:39:24Z` +- `2026-06-12T12:39:25Z` diff --git a/package.json b/package.json index a628440e..5ba7fcc4 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:browser": "node ./node_modules/@playwright/test/cli.js test", "test:browser:headed": "node ./node_modules/@playwright/test/cli.js test --headed", "test:browser:install": "node ./node_modules/@playwright/test/cli.js install chromium", - "test:api": "node ./node_modules/@playwright/test/cli.js test --config=playwright-api.config.js", + "test:api": "node ./scripts/run-playwright-api-tests.mjs", "test:api:headed": "node ./node_modules/@playwright/test/cli.js test --config=playwright-api.config.js --headed", "test:api:verbose": "node ./node_modules/@playwright/test/cli.js test --config=playwright-api.config.js --reporter=verbose", "lint": "eslint \"apps/**/*.js\" \"packages/**/*.js\"", @@ -103,6 +103,9 @@ "@anywaydata/core": "workspace:^", "@mcp-b/webmcp-polyfill": "3.0.0", "@popperjs/core": "2.11.8", + "chrono-node": "^2.9.1", + "date-fns": "^4.4.0", + "date-fns-tz": "^3.2.0", "papaparse": "5.5.3", "randexp": "0.5.3", "tippy.js": "6.3.7" diff --git a/packages/core-ui/src/tests/grid/schema/test-data-command-catalog.test.js b/packages/core-ui/src/tests/grid/schema/test-data-command-catalog.test.js index a850eb69..88e2b697 100644 --- a/packages/core-ui/src/tests/grid/schema/test-data-command-catalog.test.js +++ b/packages/core-ui/src/tests/grid/schema/test-data-command-catalog.test.js @@ -71,6 +71,7 @@ describe('Test Data Command Catalog', () => { it('should also populate domain commands for the domain dropdown section', () => { const domainCommands = getDomainCommands(); expect(domainCommands.length).toBeGreaterThan(0); + expect(domainCommands).toContain('autoIncrement.timestamp'); expect(domainCommands).toContain('number.int'); expect(domainCommands).toContain('string.counterString'); expect(domainCommands.some((command) => command.startsWith('helpers.'))).toBe(false); @@ -78,8 +79,10 @@ describe('Test Data Command Catalog', () => { it('should provide picker options with schema help metadata', () => { const values = getMethodPickerOptions(''); + const autoIncrementEntry = values.find((entry) => entry.command === 'autoIncrement.timestamp'); const domainEntry = values.find((entry) => entry.command === 'number.int'); const fakerEntry = values.find((entry) => entry.command === 'helpers.arrayElement'); + expect(autoIncrementEntry?.sourceType).toBe('domain'); expect(domainEntry?.sourceType).toBe('domain'); expect(fakerEntry?.sourceType).toBe('faker'); expect(Array.isArray(domainEntry?.helpModel?.params)).toBe(true); diff --git a/packages/core-ui/src/tests/shared/help-model-builder.test.js b/packages/core-ui/src/tests/shared/help-model-builder.test.js index 70b361a3..704f3a2b 100644 --- a/packages/core-ui/src/tests/shared/help-model-builder.test.js +++ b/packages/core-ui/src/tests/shared/help-model-builder.test.js @@ -66,6 +66,15 @@ describe('help-model-builder', () => { expect(renderSchemaHelpHtml(model)).toContain('delimiter'); }); + test('builds domain help for auto-increment timestamps with step metadata', () => { + const model = buildSchemaHelpModel('domain', 'autoIncrement.timestamp'); + + expect(model.show).toBe(true); + expect(model.heading).toContain('autoIncrement.timestamp'); + expect(renderSchemaHelpHtml(model)).toContain('run start time'); + expect(renderSchemaHelpHtml(model)).toContain('outputFormat'); + }); + test('maps faker helpers docs link to anywaydata faker helpers docs', () => { const model = buildSchemaHelpModel('faker', 'helpers.fake'); expect(model.docsUrl).toBe('https://anywaydata.com/docs/test-data/faker/helpers'); diff --git a/packages/core-ui/src/tests/utils/domain-command-help-metadata.test.js b/packages/core-ui/src/tests/utils/domain-command-help-metadata.test.js index a0f958a5..0b49c020 100644 --- a/packages/core-ui/src/tests/utils/domain-command-help-metadata.test.js +++ b/packages/core-ui/src/tests/utils/domain-command-help-metadata.test.js @@ -12,4 +12,10 @@ describe('domain command help metadata docs links', () => { expect(enumHelp).toBeTruthy(); expect(enumHelp.docsUrl).toBe('https://anywaydata.com/docs/test-data/domain/datatype'); }); + + test('maps autoIncrement timestamp help to the autoIncrement domain page', () => { + const autoIncrementHelp = getDomainCommandHelp('autoIncrement.timestamp'); + expect(autoIncrementHelp).toBeTruthy(); + expect(autoIncrementHelp.docsUrl).toBe('https://anywaydata.com/docs/test-data/domain/autoIncrement'); + }); }); diff --git a/packages/core/js/data_generation/domain/domainTestDataGenerator.js b/packages/core/js/data_generation/domain/domainTestDataGenerator.js index 982ed5b4..c231524b 100644 --- a/packages/core/js/data_generation/domain/domainTestDataGenerator.js +++ b/packages/core/js/data_generation/domain/domainTestDataGenerator.js @@ -7,7 +7,7 @@ class DomainTestDataGenerator { this.faker = aFaker; } - generateFrom(aRule) { + generateFrom(aRule, executionContext = {}) { const ruleSpec = String(aRule?.ruleSpec || '').trim(); const parsed = parseKeywordInvocation(ruleSpec); if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) { @@ -18,6 +18,7 @@ class DomainTestDataGenerator { const result = executeDomainKeyword(parsed.keyword, { faker: this.faker, args: Array.isArray(parsed.args) ? parsed.args : [], + ...executionContext, }); return dataResponse(result); } catch (error) { diff --git a/packages/core/js/data_generation/n-wise/pairwiseTestDataGenerator.js b/packages/core/js/data_generation/n-wise/pairwiseTestDataGenerator.js index b33d1787..d78cf7d7 100644 --- a/packages/core/js/data_generation/n-wise/pairwiseTestDataGenerator.js +++ b/packages/core/js/data_generation/n-wise/pairwiseTestDataGenerator.js @@ -202,7 +202,8 @@ export class PairwiseTestDataGenerator { * for non-enum rules */ generateCompleteDataRecords(enumCombinations) { - return enumCombinations.map((enumRecord) => { + const runStartedAt = new Date(); + return enumCombinations.map((enumRecord, rowIndex) => { const completeRecord = {}; // Preserve original schema order across enum and non-enum columns. @@ -210,7 +211,7 @@ export class PairwiseTestDataGenerator { if (this.isRuleType(rule, 'enum')) { completeRecord[rule.name] = enumRecord.get(rule.name); } else { - completeRecord[rule.name] = this.generateRandomValueForRule(rule); + completeRecord[rule.name] = this.generateRandomValueForRule(rule, { rowIndex, runStartedAt }); } } @@ -221,7 +222,7 @@ export class PairwiseTestDataGenerator { /** * Generate a random value for a non-enum rule */ - generateRandomValueForRule(rule) { + generateRandomValueForRule(rule, generationContext = {}) { switch (rule.type) { case 'literal': return rule.ruleSpec; @@ -236,7 +237,7 @@ export class PairwiseTestDataGenerator { return this.generateRegexValue(rule); case 'domain': - return this.generateDomainValue(rule); + return this.generateDomainValue(rule, generationContext); default: return `random_${rule.name}_${Math.floor(Math.random() * 1000)}`; @@ -302,10 +303,10 @@ export class PairwiseTestDataGenerator { /** * Generate a value using domain keywords */ - generateDomainValue(rule) { + generateDomainValue(rule, generationContext = {}) { try { if (this.domainGenerator) { - const result = this.domainGenerator.generateFrom(rule); + const result = this.domainGenerator.generateFrom(rule, generationContext); return result.data ?? `domain_${rule.ruleSpec}_${Math.floor(Math.random() * 1000)}`; } return `domain_${rule.ruleSpec}_${Math.floor(Math.random() * 1000)}`; diff --git a/packages/core/js/data_generation/rulesBasedDataGenerator.js b/packages/core/js/data_generation/rulesBasedDataGenerator.js index d7d9ce7b..d0a82540 100644 --- a/packages/core/js/data_generation/rulesBasedDataGenerator.js +++ b/packages/core/js/data_generation/rulesBasedDataGenerator.js @@ -29,15 +29,16 @@ export class RulesBasedDataGenerator { const headers = fromRules.map((rule) => rule.name); data.push(headers); + const runStartedAt = new Date(); for (var row = 0; row < thisMany; row++) { - const aRow = this.generateRandomRow(fromRules, options); + const aRow = this.generateRandomRow(fromRules, { ...options, rowIndex: row, runStartedAt }); data.push(aRow); } return data; } - generateRandomRow(fromRules, { constraints = [], maxAttempts = 1 } = {}) { + generateRandomRow(fromRules, { constraints = [], maxAttempts = 1, rowIndex = 0, runStartedAt = new Date() } = {}) { const rules = Array.isArray(fromRules) ? fromRules : []; const safeConstraints = Array.isArray(constraints) ? constraints : []; @@ -72,7 +73,7 @@ export class RulesBasedDataGenerator { generator = this.defaultGenerator; } - value = generator.generateFrom(rule); + value = generator.generateFrom(rule, { rowIndex, runStartedAt }); if (value.isError) { dataGen = '**ERROR**'; diff --git a/packages/core/js/data_generation/testDataGenerator.js b/packages/core/js/data_generation/testDataGenerator.js index 3420d5ae..a02e0d79 100644 --- a/packages/core/js/data_generation/testDataGenerator.js +++ b/packages/core/js/data_generation/testDataGenerator.js @@ -76,9 +76,12 @@ export class TestDataGenerator { generateRow() { this.runtimeErrors = []; + const runStartedAt = new Date(); const generatedRow = this.generator.generateRandomRow(this.rulesParser.testDataRules.rules, { constraints: this.rulesParser.testDataRules.constraints, maxAttempts: 1000, + rowIndex: 0, + runStartedAt, }); if (!generatedRow) { this.runtimeErrors = [SchemaParsingErrors.constraintGenerationFailed()]; diff --git a/packages/core/js/domain/auto-increment-timestamp.js b/packages/core/js/domain/auto-increment-timestamp.js new file mode 100644 index 00000000..164be099 --- /dev/null +++ b/packages/core/js/domain/auto-increment-timestamp.js @@ -0,0 +1,187 @@ +import { parseDate as parseNaturalDate } from 'chrono-node'; +import { + addDays, + addHours, + addMilliseconds, + addMinutes, + addMonths, + addSeconds, + addWeeks, + addYears, + isValid, + parse, + parseISO, +} from 'date-fns'; +import { formatInTimeZone } from 'date-fns-tz'; + +const DEFAULT_OUTPUT_FORMAT = 'iso8601'; +const DEFAULT_STEP_TYPE = 'seconds'; +const DEFAULT_STEP_VALUE = 1; +const ISO_OUTPUT_ALIASES = new Set(['iso', 'iso8601', 'iso-8601']); +const STEP_TYPE_NORMALISERS = Object.freeze({ + millisecond: 'milliseconds', + milliseconds: 'milliseconds', + second: 'seconds', + seconds: 'seconds', + minute: 'minutes', + minutes: 'minutes', + hour: 'hours', + hours: 'hours', + day: 'days', + days: 'days', + week: 'weeks', + weeks: 'weeks', + month: 'months', + months: 'months', + year: 'years', + years: 'years', +}); + +function cloneDate(value) { + return new Date(value.getTime()); +} + +function toUtcDatePreservingClock(dateValue) { + return new Date( + Date.UTC( + dateValue.getFullYear(), + dateValue.getMonth(), + dateValue.getDate(), + dateValue.getHours(), + dateValue.getMinutes(), + dateValue.getSeconds(), + dateValue.getMilliseconds() + ) + ); +} + +function isIsoLikeDateString(value) { + return /^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$/.test(String(value || '').trim()); +} + +function ensureValidDate(dateValue, description) { + if (!isValid(dateValue)) { + throw new Error(`Invalid ${description}.`); + } + return dateValue; +} + +function normaliseStepType(stepType) { + const raw = String(stepType || DEFAULT_STEP_TYPE) + .trim() + .toLowerCase(); + const normalised = STEP_TYPE_NORMALISERS[raw]; + if (!normalised) { + throw new Error( + 'Invalid argument for type: expected milliseconds, seconds, minutes, hours, days, weeks, months, or years.' + ); + } + return normalised; +} + +function getNumericArg(value, argName) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Invalid argument for ${argName}: expected a number.`); + } + return value; +} + +function resolveNow(executionContext = {}) { + if (executionContext.runStartedAt instanceof Date) { + return cloneDate(executionContext.runStartedAt); + } + if (typeof executionContext.nowProvider === 'function') { + const provided = executionContext.nowProvider(); + if (provided instanceof Date) { + return cloneDate(provided); + } + } + return new Date(); +} + +function resolveStartDate(startValue, inputFormat, fallbackNow) { + if (startValue instanceof Date) { + return ensureValidDate(cloneDate(startValue), 'start date'); + } + + if (typeof startValue === 'number') { + return ensureValidDate(new Date(startValue), 'start date'); + } + + if (typeof startValue === 'undefined') { + return cloneDate(fallbackNow); + } + + const startText = String(startValue || '').trim(); + if (!startText) { + return cloneDate(fallbackNow); + } + + if (typeof inputFormat === 'string' && inputFormat.trim().length > 0) { + const parsedWithFormat = ensureValidDate(parse(startText, inputFormat, fallbackNow), 'start date'); + return toUtcDatePreservingClock(parsedWithFormat); + } + + if (isIsoLikeDateString(startText)) { + return ensureValidDate(parseISO(startText), 'start date'); + } + + const naturalDate = parseNaturalDate(startText, fallbackNow, { forwardDate: false }); + if (naturalDate instanceof Date) { + return toUtcDatePreservingClock(ensureValidDate(naturalDate, 'start date')); + } + + return ensureValidDate(new Date(startText), 'start date'); +} + +function addStep(dateValue, amount, stepType) { + switch (stepType) { + case 'milliseconds': + return addMilliseconds(dateValue, amount); + case 'seconds': + return addSeconds(dateValue, amount); + case 'minutes': + return addMinutes(dateValue, amount); + case 'hours': + return addHours(dateValue, amount); + case 'days': + return addDays(dateValue, amount); + case 'weeks': + return addWeeks(dateValue, amount); + case 'months': + return addMonths(dateValue, amount); + case 'years': + return addYears(dateValue, amount); + default: + throw new Error(`Unsupported step type: ${stepType}`); + } +} + +function formatTimestamp(dateValue, outputFormat) { + const formatValue = String(outputFormat || DEFAULT_OUTPUT_FORMAT) + .trim() + .toLowerCase(); + if (!formatValue || ISO_OUTPUT_ALIASES.has(formatValue)) { + return dateValue.toISOString().replace(/\.\d{3}Z$/, 'Z'); + } + return formatInTimeZone(dateValue, 'UTC', outputFormat); +} + +function executeCustomAutoIncrementTimestamp(executionContext = {}) { + const args = Array.isArray(executionContext.args) ? executionContext.args : []; + const startArg = args[0]; + const stepArg = args[1]; + const typeArg = args[2]; + const outputFormatArg = args[3]; + const inputFormatArg = args[4]; + const rowIndex = + Number.isInteger(executionContext.rowIndex) && executionContext.rowIndex > 0 ? executionContext.rowIndex : 0; + const now = resolveNow(executionContext); + const startDate = resolveStartDate(startArg, inputFormatArg, now); + const stepValue = typeof stepArg === 'undefined' ? DEFAULT_STEP_VALUE : getNumericArg(stepArg, 'step'); + const stepType = normaliseStepType(typeArg); + const offsetDate = rowIndex === 0 ? startDate : addStep(startDate, stepValue * rowIndex, stepType); + return formatTimestamp(offsetDate, outputFormatArg); +} + +export { executeCustomAutoIncrementTimestamp }; diff --git a/packages/core/js/domain/domain-custom-autoincrement-keyword-definitions.js b/packages/core/js/domain/domain-custom-autoincrement-keyword-definitions.js new file mode 100644 index 00000000..69b125b6 --- /dev/null +++ b/packages/core/js/domain/domain-custom-autoincrement-keyword-definitions.js @@ -0,0 +1,65 @@ +const DOMAIN_CUSTOM_AUTOINCREMENT_KEYWORD_DEFINITIONS = [ + { + keyword: 'autoIncrement.timestamp', + delegate: { + type: 'custom', + target: 'autoIncrement.timestamp', + }, + help: { + summary: + 'Generates a timestamp that starts from a fixed point and increments by the configured amount for each generated row.', + docsUrl: '/docs/test-data/domain/autoIncrement', + example: 'autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=1, type="seconds")', + examples: [ + 'autoIncrement.timestamp()', + 'autoIncrement.timestamp(start="20/03/1969", step=1, type="days")', + 'autoIncrement.timestamp(start="2026-06-12 12:39:23", step=15, type="minutes", outputFormat="yyyy-MM-dd HH:mm:ss")', + ], + exampleReturnValues: ['2026-06-12T12:39:23Z', '2026-06-12T12:39:24Z', '2026-06-12T12:39:25Z'], + returnType: 'string', + args: [ + { + name: 'start', + type: 'string|number', + required: false, + description: + 'Starting timestamp. Defaults to the generation run start time. Strings may be ISO-8601, a date-fns formatted value when inputFormat is provided, or a permissive natural-language date parsed with chrono-node.', + examples: ['2026-06-12T12:39:23Z', '20/03/1969', 1718195963000], + }, + { + name: 'step', + type: 'number', + required: false, + description: 'Amount added for each generated row. Defaults to 1.', + examples: [1, 15], + }, + { + name: 'type', + type: 'string', + required: false, + description: + 'Unit applied to step for each row. Supports milliseconds, seconds, minutes, hours, days, weeks, months, or years. Defaults to seconds.', + examples: ['seconds', 'days'], + }, + { + name: 'outputFormat', + type: 'string', + required: false, + description: + 'Output format. Defaults to ISO-8601 without milliseconds. Use "iso8601" for the default behaviour or a date-fns format string.', + examples: ['iso8601', "yyyy-MM-dd'T'HH:mm:ssXXX"], + }, + { + name: 'inputFormat', + type: 'string', + required: false, + description: + 'Optional date-fns parse format used only for the start argument when that value is not ISO-8601.', + examples: ['dd/MM/yyyy', 'yyyy-MM-dd HH:mm:ss'], + }, + ], + }, + }, +]; + +export { DOMAIN_CUSTOM_AUTOINCREMENT_KEYWORD_DEFINITIONS }; diff --git a/packages/core/js/domain/domain-keyword-definitions.js b/packages/core/js/domain/domain-keyword-definitions.js index 17eb5775..d75729b5 100644 --- a/packages/core/js/domain/domain-keyword-definitions.js +++ b/packages/core/js/domain/domain-keyword-definitions.js @@ -13,6 +13,7 @@ import { DOMAIN_FAKER_GIT_KEYWORD_DEFINITIONS } from './domain-faker-git-keyword import { DOMAIN_FAKER_HACKER_KEYWORD_DEFINITIONS } from './domain-faker-hacker-keyword-definitions.js'; import { DOMAIN_FAKER_IMAGE_KEYWORD_DEFINITIONS } from './domain-faker-image-keyword-definitions.js'; import { DOMAIN_FAKER_INTERNET_KEYWORD_DEFINITIONS } from './domain-faker-internet-keyword-definitions.js'; +import { DOMAIN_CUSTOM_AUTOINCREMENT_KEYWORD_DEFINITIONS } from './domain-custom-autoincrement-keyword-definitions.js'; import { DOMAIN_CUSTOM_LITERAL_KEYWORD_DEFINITIONS } from './domain-custom-literal-keyword-definitions.js'; import { DOMAIN_FAKER_LOCATION_KEYWORD_DEFINITIONS } from './domain-faker-location-keyword-definitions.js'; import { DOMAIN_FAKER_LOREM_KEYWORD_DEFINITIONS } from './domain-faker-lorem-keyword-definitions.js'; @@ -43,6 +44,7 @@ const DOMAIN_KEYWORD_DEFINITIONS = [ ...DOMAIN_FAKER_HACKER_KEYWORD_DEFINITIONS, ...DOMAIN_FAKER_IMAGE_KEYWORD_DEFINITIONS, ...DOMAIN_FAKER_INTERNET_KEYWORD_DEFINITIONS, + ...DOMAIN_CUSTOM_AUTOINCREMENT_KEYWORD_DEFINITIONS, ...DOMAIN_CUSTOM_LITERAL_KEYWORD_DEFINITIONS, ...DOMAIN_FAKER_LOCATION_KEYWORD_DEFINITIONS, ...DOMAIN_FAKER_LOREM_KEYWORD_DEFINITIONS, diff --git a/packages/core/js/domain/domain-keywords.js b/packages/core/js/domain/domain-keywords.js index c24d67a1..f8cebd63 100644 --- a/packages/core/js/domain/domain-keywords.js +++ b/packages/core/js/domain/domain-keywords.js @@ -1,4 +1,5 @@ import { DOMAIN_KEYWORD_DEFINITIONS } from './domain-keyword-definitions.js'; +import { executeCustomAutoIncrementTimestamp } from './auto-increment-timestamp.js'; import { executeCustomCounterString } from './counterstring.js'; const DOMAIN_ROOT_PREFIX = 'awd.domain.'; @@ -223,6 +224,7 @@ function applyFakerArgTransform(keyword, args = []) { } const BUILT_IN_CUSTOM_DELEGATES = { + 'autoIncrement.timestamp': executeCustomAutoIncrementTimestamp, 'literal.value': (executionContext = {}) => { const args = Array.isArray(executionContext.args) ? executionContext.args : []; return typeof args[0] === 'undefined' ? '' : args[0]; diff --git a/packages/core/package.json b/packages/core/package.json index 2426aa97..75949d95 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,6 +28,9 @@ }, "dependencies": { "@faker-js/faker": "9.7.0", + "chrono-node": "^2.9.1", + "date-fns": "^4.4.0", + "date-fns-tz": "^3.2.0", "papaparse": "5.5.3", "randexp": "0.5.3" } diff --git a/packages/core/src/tests/data_generation/autoincrement-timestamp-integration.test.js b/packages/core/src/tests/data_generation/autoincrement-timestamp-integration.test.js new file mode 100644 index 00000000..1fb13491 --- /dev/null +++ b/packages/core/src/tests/data_generation/autoincrement-timestamp-integration.test.js @@ -0,0 +1,49 @@ +import { faker } from '@faker-js/faker'; +import RandExp from 'randexp'; +import { TestDataGenerator } from '../../../js/data_generation/testDataGenerator.js'; +import { PairwiseTestDataGenerator } from '../../../js/data_generation/n-wise/pairwiseTestDataGenerator.js'; +import { TestDataRule } from '../../../js/data_generation/testDataRule.js'; + +describe('autoIncrement.timestamp integration', () => { + test('increments across generated rows in normal rules-based generation', () => { + const generator = new TestDataGenerator(faker, RandExp); + generator.importSpec('CreatedAt\nautoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=1, type="seconds")'); + generator.compile(); + + expect(generator.isValid()).toBe(true); + + const rows = generator.generate(3); + expect(rows).toEqual([['CreatedAt'], ['2026-06-12T12:39:23Z'], ['2026-06-12T12:39:24Z'], ['2026-06-12T12:39:25Z']]); + }); + + test('increments across generated rows in pairwise output alongside enum columns', () => { + const rules = [ + new TestDataRule('Browser', 'enum("Chrome","Firefox")'), + new TestDataRule('Theme', 'enum("Light","Dark")'), + new TestDataRule( + 'CreatedAt', + 'autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=5, type="minutes", outputFormat="yyyy-MM-dd HH:mm:ss")' + ), + ]; + + rules[0].type = 'enum'; + rules[1].type = 'enum'; + rules[2].type = 'domain'; + + const generator = new PairwiseTestDataGenerator(faker); + const initResult = generator.initializeFromRules(rules); + expect(initResult.isError).toBe(false); + + const result = generator.generateAllDataRecordsAsRows(); + expect(result.isError).toBe(false); + + const rows = result.data.data; + expect(rows[0]).toEqual(['Browser', 'Theme', 'CreatedAt']); + expect(rows.slice(1).map((row) => row[2])).toEqual([ + '2026-06-12 12:39:23', + '2026-06-12 12:44:23', + '2026-06-12 12:49:23', + '2026-06-12 12:54:23', + ]); + }); +}); diff --git a/packages/core/src/tests/data_generation/unit/domain/domain-autoincrement-timestamp-exec.test.js b/packages/core/src/tests/data_generation/unit/domain/domain-autoincrement-timestamp-exec.test.js new file mode 100644 index 00000000..26bf8601 --- /dev/null +++ b/packages/core/src/tests/data_generation/unit/domain/domain-autoincrement-timestamp-exec.test.js @@ -0,0 +1,57 @@ +import { executeDomainKeyword } from '../../../../../js/domain/domain-keywords.js'; +import { parseKeywordInvocation } from '../../../../../js/domain/domain-keyword-parser.js'; + +describe('autoIncrement.timestamp domain keyword execution', () => { + test('uses the generation run start time by default and increments by seconds per row', () => { + const runStartedAt = new Date('2026-06-12T12:39:23.000Z'); + + expect(executeDomainKeyword('autoIncrement.timestamp', { args: [], rowIndex: 0, runStartedAt })).toBe( + '2026-06-12T12:39:23Z' + ); + expect(executeDomainKeyword('autoIncrement.timestamp', { args: [], rowIndex: 1, runStartedAt })).toBe( + '2026-06-12T12:39:24Z' + ); + expect(executeDomainKeyword('autoIncrement.timestamp', { args: [], rowIndex: 2, runStartedAt })).toBe( + '2026-06-12T12:39:25Z' + ); + }); + + test('supports explicit ISO timestamps and day increments', () => { + const args = ['2026-06-12T12:39:23Z', 2, 'days']; + + expect(executeDomainKeyword('autoIncrement.timestamp', { args, rowIndex: 0 })).toBe('2026-06-12T12:39:23Z'); + expect(executeDomainKeyword('autoIncrement.timestamp', { args, rowIndex: 1 })).toBe('2026-06-14T12:39:23Z'); + expect(executeDomainKeyword('autoIncrement.timestamp', { args, rowIndex: 2 })).toBe('2026-06-16T12:39:23Z'); + }); + + test('supports permissive non-ISO parsing with custom output formatting', () => { + const parsed = parseKeywordInvocation( + 'autoIncrement.timestamp(start="20/03/1969", step=1, type="days", outputFormat="yyyy-MM-dd")' + ); + + expect(parsed.errors).toEqual([]); + expect(executeDomainKeyword(parsed.keyword, { args: parsed.args, rowIndex: 0 })).toBe('1969-03-20'); + expect(executeDomainKeyword(parsed.keyword, { args: parsed.args, rowIndex: 3 })).toBe('1969-03-23'); + }); + + test('supports inputFormat when the start value is not ISO-8601', () => { + const parsed = parseKeywordInvocation( + 'autoIncrement.timestamp(start="12-06-2026 12:39:23", step=15, type="minutes", outputFormat="yyyy-MM-dd HH:mm:ss", inputFormat="dd-MM-yyyy HH:mm:ss")' + ); + + expect(parsed.errors).toEqual([]); + expect(executeDomainKeyword(parsed.keyword, { args: parsed.args, rowIndex: 0 })).toBe('2026-06-12 12:39:23'); + expect(executeDomainKeyword(parsed.keyword, { args: parsed.args, rowIndex: 2 })).toBe('2026-06-12 13:09:23'); + }); + + test('rejects unsupported increment units', () => { + expect(() => + executeDomainKeyword('autoIncrement.timestamp', { + args: ['2026-06-12T12:39:23Z', 1, 'fortnights'], + rowIndex: 0, + }) + ).toThrow( + 'Invalid argument for type: expected milliseconds, seconds, minutes, hours, days, weeks, months, or years.' + ); + }); +}); diff --git a/packages/core/src/tests/data_generation/unit/domain/domainKeywords.test.js b/packages/core/src/tests/data_generation/unit/domain/domainKeywords.test.js index 292801ac..39253f71 100644 --- a/packages/core/src/tests/data_generation/unit/domain/domainKeywords.test.js +++ b/packages/core/src/tests/data_generation/unit/domain/domainKeywords.test.js @@ -151,6 +151,8 @@ describe('domain keyword catalog', () => { expect(byKeyword.get('finance.accountNumber')?.help?.returnType).toBe('string'); expect(byKeyword.get('date.month')?.help?.returnType).toBe('string'); expect(byKeyword.get('date.weekday')?.help?.returnType).toBe('string'); + expect(byKeyword.get('autoIncrement.timestamp')?.delegate?.type).toBe('custom'); + expect(byKeyword.get('autoIncrement.timestamp')?.help?.returnType).toBe('string'); expect(byKeyword.get('internet.email')?.delegate?.argTransform).toBe('optionsFromHelpArgs'); expect(byKeyword.get('internet.httpStatusCode')?.help?.returnType).toBe('number'); expect(byKeyword.get('internet.port')?.help?.returnType).toBe('number'); diff --git a/playwright-api.config.js b/playwright-api.config.js index 8872f435..9e7ca3c3 100644 --- a/playwright-api.config.js +++ b/playwright-api.config.js @@ -8,7 +8,7 @@ module.exports = defineConfig({ testDir: './apps/api/src/tests', testMatch: '**/*.spec.js', timeout: 30000, - workers: process.env.CI ? 6 : 4, + workers: 1, expect: { timeout: 15000, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c21618db..4a2ff2b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,15 @@ importers: '@popperjs/core': specifier: 2.11.8 version: 2.11.8 + chrono-node: + specifier: ^2.9.1 + version: 2.9.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + date-fns-tz: + specifier: ^3.2.0 + version: 3.2.0(date-fns@4.4.0) papaparse: specifier: 5.5.3 version: 5.5.3 @@ -185,6 +194,15 @@ importers: '@faker-js/faker': specifier: 9.7.0 version: 9.7.0 + chrono-node: + specifier: ^2.9.1 + version: 2.9.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + date-fns-tz: + specifier: ^3.2.0 + version: 3.2.0(date-fns@4.4.0) papaparse: specifier: 5.5.3 version: 5.5.3 @@ -3807,6 +3825,10 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + chrono-node@2.9.1: + resolution: {integrity: sha512-nqP8Zp11efCYQIESXPxeDM8ikzN5BDb3Zzou+a66fZq+X2hzKFdsNLQE2/uBAh//BZEMbaMo1eTnagK7hOenAg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -4142,6 +4164,14 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -13357,6 +13387,8 @@ snapshots: chrome-trace-event@1.0.4: {} + chrono-node@2.9.1: {} + ci-info@3.9.0: {} ci-info@4.4.0: {} @@ -13717,6 +13749,12 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + date-fns-tz@3.2.0(date-fns@4.4.0): + dependencies: + date-fns: 4.4.0 + + date-fns@4.4.0: {} + debounce@1.2.1: {} debug@2.6.9: diff --git a/scripts/run-playwright-api-tests.mjs b/scripts/run-playwright-api-tests.mjs new file mode 100644 index 00000000..2ccd65cb --- /dev/null +++ b/scripts/run-playwright-api-tests.mjs @@ -0,0 +1,32 @@ +import { mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const cacheDir = path.join(repoRoot, 'test-results', 'playwright-api-transform-cache'); + +await rm(cacheDir, { recursive: true, force: true }); +await mkdir(cacheDir, { recursive: true }); + +const child = spawn( + process.execPath, + ['./node_modules/@playwright/test/cli.js', 'test', '--config=playwright-api.config.js'], + { + cwd: repoRoot, + stdio: 'inherit', + env: { + ...process.env, + PWTEST_CACHE_DIR: cacheDir, + }, + } +); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/scripts/run-storybook-tests.mjs b/scripts/run-storybook-tests.mjs index 76a03cb1..27a078b9 100644 --- a/scripts/run-storybook-tests.mjs +++ b/scripts/run-storybook-tests.mjs @@ -38,7 +38,7 @@ function ensurePortFree(port) { ensurePortFree(6006); -const child = spawn('pnpm', ['exec', 'vitest', '--project=storybook', '--run'], { +const child = spawn('pnpm', ['exec', 'vitest', '--project=storybook', '--run', '--api.host=127.0.0.1'], { stdio: 'inherit', shell: process.platform === 'win32', }); diff --git a/vitest.config.mjs b/vitest.config.mjs index caffd408..b2876af1 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -16,7 +16,7 @@ export default defineConfig({ storybookTest({ configDir: path.join(dirname, '.storybook'), storybookUrl: 'http://127.0.0.1:6006', - storybookScript: 'pnpm exec storybook dev -p 6006 --exact-port --ci', + storybookScript: 'pnpm exec storybook dev -p 6006 --host 127.0.0.1 --exact-port --ci', }), ], test: { @@ -27,6 +27,11 @@ export default defineConfig({ enabled: true, headless: true, provider: playwright({}), + api: { + host: '127.0.0.1', + port: 51315, + strictPort: true, + }, instances: [{ browser: 'chromium' }], }, }, From 3acfef373146afbdf0ff0527b4e79d7f21950d1e Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sat, 13 Jun 2026 17:27:44 +0100 Subject: [PATCH 2/7] Fix auto increment timestamp parity across surfaces --- apps/mcp/src/mcp.test.js | 20 +++++++++++++++ .../040-test-data/domain/040-autoIncrement.md | 6 ++--- .../js/domain/auto-increment-timestamp.js | 8 ++++++ ...ustom-autoincrement-keyword-definitions.js | 6 ++--- packages/core/src/index.js | 5 ++++ .../core-api/generateFromTextSpec.test.js | 7 ++++++ .../generateDataFromMultiLineSpec.test.js | 11 ++++++++ ...omain-autoincrement-timestamp-exec.test.js | 25 +++++++++++++++++++ .../tests/mcp/anywaydata-mcp-contract.test.js | 11 ++++++++ 9 files changed, 93 insertions(+), 6 deletions(-) diff --git a/apps/mcp/src/mcp.test.js b/apps/mcp/src/mcp.test.js index 3a26e4ac..7ae75276 100644 --- a/apps/mcp/src/mcp.test.js +++ b/apps/mcp/src/mcp.test.js @@ -54,6 +54,26 @@ test('MCP server handles generate_data_from_spec tool call', () => { expect(response?.result?.structuredContent?.ok).toBe(true); }); +test('MCP server allows registered domain commands in safe mode', () => { + const response = requestServer({ + jsonrpc: '2.0', + id: 202, + method: 'tools/call', + params: { + name: 'generate_data_from_spec', + arguments: { + textSpec: 't2\nautoIncrement.timestamp(start="12th June 2026 at 4pm", step=60, type="minutes")', + rowCount: 3, + outputFormat: 'json', + }, + }, + }); + const payload = JSON.parse(response?.result?.content?.[0]?.text || '{}'); + expect(payload.ok).toBe(true); + expect(payload.rows).toEqual([['2026-06-12T16:00:00Z'], ['2026-06-12T17:00:00Z'], ['2026-06-12T18:00:00Z']]); + expect(response?.result?.isError).toBe(false); +}); + test('MCP server accepts comments and blank lines in textSpec', () => { const response = requestServer({ jsonrpc: '2.0', diff --git a/docs-src/docs/040-test-data/domain/040-autoIncrement.md b/docs-src/docs/040-test-data/domain/040-autoIncrement.md index b70481c7..e36a3c0e 100644 --- a/docs-src/docs/040-test-data/domain/040-autoIncrement.md +++ b/docs-src/docs/040-test-data/domain/040-autoIncrement.md @@ -52,11 +52,11 @@ Generates a timestamp that starts from a fixed point and increments by the confi | Arg | Type | Required | Description | | --- | --- | --- | --- | -| `start` | `string\|number` | no | Starting timestamp. Defaults to the generation run start time. Strings may be ISO-8601, a date-fns formatted value when `inputFormat` is provided, or a permissive natural-language date parsed with chrono-node. | +| `start` | `string\|number` | no | Starting timestamp. Defaults to the generation run start time. Valid examples include `2026-06-12T12:39:23Z`, `20/03/1969`, `12-06-2026 12:39:23`, or a Unix timestamp such as `1718195963000`. | | `step` | `number` | no | Amount added for each generated row. Defaults to `1`. | | `type` | `string` | no | Unit applied to `step` for each row. Supports `milliseconds`, `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, or `years`. Defaults to `seconds`. | -| `outputFormat` | `string` | no | Output format. Defaults to ISO-8601 without milliseconds. Use `iso8601` for the default behaviour or a date-fns format string. | -| `inputFormat` | `string` | no | Optional date-fns parse format used only for `start` when that value is not ISO-8601. | +| `outputFormat` | `string` | no | Output format. Defaults to ISO-8601 without milliseconds. Use `iso8601` for the default behaviour or a custom pattern such as `yyyy-MM-dd HH:mm:ss`. | +| `inputFormat` | `string` | no | Optional parse pattern used only for `start` when you want to match a specific text shape such as `dd/MM/yyyy` or `dd-MM-yyyy HH:mm:ss`. | Examples: diff --git a/packages/core/js/domain/auto-increment-timestamp.js b/packages/core/js/domain/auto-increment-timestamp.js index 164be099..a2565664 100644 --- a/packages/core/js/domain/auto-increment-timestamp.js +++ b/packages/core/js/domain/auto-increment-timestamp.js @@ -174,12 +174,20 @@ function executeCustomAutoIncrementTimestamp(executionContext = {}) { const typeArg = args[2]; const outputFormatArg = args[3]; const inputFormatArg = args[4]; + const state = executionContext.autoIncrementState || null; const rowIndex = Number.isInteger(executionContext.rowIndex) && executionContext.rowIndex > 0 ? executionContext.rowIndex : 0; const now = resolveNow(executionContext); const startDate = resolveStartDate(startArg, inputFormatArg, now); const stepValue = typeof stepArg === 'undefined' ? DEFAULT_STEP_VALUE : getNumericArg(stepArg, 'step'); const stepType = normaliseStepType(typeArg); + + if (state && typeof state === 'object') { + const currentDate = state.nextDate instanceof Date ? cloneDate(state.nextDate) : startDate; + state.nextDate = addStep(cloneDate(currentDate), stepValue, stepType); + return formatTimestamp(currentDate, outputFormatArg); + } + const offsetDate = rowIndex === 0 ? startDate : addStep(startDate, stepValue * rowIndex, stepType); return formatTimestamp(offsetDate, outputFormatArg); } diff --git a/packages/core/js/domain/domain-custom-autoincrement-keyword-definitions.js b/packages/core/js/domain/domain-custom-autoincrement-keyword-definitions.js index c9e8ffc0..709347ac 100644 --- a/packages/core/js/domain/domain-custom-autoincrement-keyword-definitions.js +++ b/packages/core/js/domain/domain-custom-autoincrement-keyword-definitions.js @@ -23,7 +23,7 @@ const DOMAIN_CUSTOM_AUTOINCREMENT_KEYWORD_DEFINITIONS = [ type: 'string|number', required: false, description: - 'Starting timestamp. Defaults to the generation run start time. Strings may be ISO-8601, a date-fns formatted value when inputFormat is provided, or a permissive natural-language date parsed with chrono-node.', + 'Starting timestamp. Defaults to the generation run start time. Valid examples include "2026-06-12T12:39:23Z", "20/03/1969", "12-06-2026 12:39:23", or a Unix timestamp like 1718195963000.', examples: ['2026-06-12T12:39:23Z', '20/03/1969', 1718195963000], }, { @@ -46,7 +46,7 @@ const DOMAIN_CUSTOM_AUTOINCREMENT_KEYWORD_DEFINITIONS = [ type: 'string', required: false, description: - 'Output format. Defaults to ISO-8601 without milliseconds. Use "iso8601" for the default behaviour or a date-fns format string.', + 'Output format. Defaults to ISO-8601 without milliseconds. Use "iso8601" for the default behaviour or a custom pattern such as "yyyy-MM-dd HH:mm:ss".', examples: ['iso8601', "yyyy-MM-dd'T'HH:mm:ssXXX"], }, { @@ -54,7 +54,7 @@ const DOMAIN_CUSTOM_AUTOINCREMENT_KEYWORD_DEFINITIONS = [ type: 'string', required: false, description: - 'Optional date-fns parse format used only for the start argument when that value is not ISO-8601.', + 'Optional parse pattern used only for the start argument when you want to match a specific text shape such as "dd/MM/yyyy" or "dd-MM-yyyy HH:mm:ss".', examples: ['dd/MM/yyyy', 'yyyy-MM-dd HH:mm:ss'], }, ], diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 7547f98d..68b27888 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -14,6 +14,7 @@ import { import { looksLikeInlineRuleSpec, startsConstraint } from '../js/data_generation/inline-schema-rule.js'; import { parseSchemaText } from '../js/data_generation/schema-conversion.js'; import { hasSafeFakerLiteralArguments } from '../js/data_generation/faker/safeLiteralArgumentParser.js'; +import { getDomainKeywordByAlias } from '../js/domain/domain-keywords.js'; import { OPTION_KEYS_BY_FORMAT, OPTION_TIPS_BY_FORMAT, @@ -153,6 +154,10 @@ export function validateSafeFakerRules(textSpec) { } const fakerCommand = getFakerCommandFromRule(ruleLine); + if (getDomainKeywordByAlias(fakerCommand)) { + continue; + } + if (!knownCommandSet.has(fakerCommand)) { return { ok: false, diff --git a/packages/core/src/tests/core-api/generateFromTextSpec.test.js b/packages/core/src/tests/core-api/generateFromTextSpec.test.js index 6135f5f6..d5733fd4 100644 --- a/packages/core/src/tests/core-api/generateFromTextSpec.test.js +++ b/packages/core/src/tests/core-api/generateFromTextSpec.test.js @@ -278,6 +278,13 @@ test('validateSafeFakerRules accepts pict-style inline faker commands', () => { expect(result.ok).toBe(true); }); +test('validateSafeFakerRules accepts registered domain keywords', () => { + const result = validateSafeFakerRules( + 'CreatedAt\nautoIncrement.timestamp(start="12th June 2026 at 4pm", step=60, type="minutes")' + ); + expect(result.ok).toBe(true); +}); + test('validateSafeFakerRules accepts js-style object literal faker args', () => { const result = validateSafeFakerRules('Template\nhelpers.mustache("{{name}}", { name: "Ada" })'); expect(result.ok).toBe(true); diff --git a/packages/core/src/tests/data_generation/generateDataFromMultiLineSpec.test.js b/packages/core/src/tests/data_generation/generateDataFromMultiLineSpec.test.js index 382b3c7c..03c939dd 100644 --- a/packages/core/src/tests/data_generation/generateDataFromMultiLineSpec.test.js +++ b/packages/core/src/tests/data_generation/generateDataFromMultiLineSpec.test.js @@ -176,6 +176,17 @@ describe('TestDataGenerator meets Acceptance Criteria', () => { expect(generator.generateRow()).toEqual([10]); }); + + test('increments auto increment timestamp when rows are generated one at a time', () => { + const generator = new TestDataGenerator(faker, RandExp); + + generator.importSpec('CreatedAt\nautoIncrement.timestamp(start="2026-06-12T16:00:00Z",step=1,type="hours")'); + generator.compile(); + + expect(generator.generateRow()).toEqual(['2026-06-12T16:00:00Z']); + expect(generator.generateRow()).toEqual(['2026-06-12T17:00:00Z']); + expect(generator.generateRow()).toEqual(['2026-06-12T18:00:00Z']); + }); }); describe('TestDataGenerator parsing detects errors in spec', () => { diff --git a/packages/core/src/tests/data_generation/unit/domain/domain-autoincrement-timestamp-exec.test.js b/packages/core/src/tests/data_generation/unit/domain/domain-autoincrement-timestamp-exec.test.js index 26bf8601..ad0e6dbf 100644 --- a/packages/core/src/tests/data_generation/unit/domain/domain-autoincrement-timestamp-exec.test.js +++ b/packages/core/src/tests/data_generation/unit/domain/domain-autoincrement-timestamp-exec.test.js @@ -54,4 +54,29 @@ describe('autoIncrement.timestamp domain keyword execution', () => { 'Invalid argument for type: expected milliseconds, seconds, minutes, hours, days, weeks, months, or years.' ); }); + + test('advances via auto increment state when rows are generated one at a time', () => { + const state = {}; + + expect( + executeDomainKeyword('autoIncrement.timestamp', { + args: ['2026-06-12T16:00:00Z', 1, 'hours'], + autoIncrementState: state, + }) + ).toBe('2026-06-12T16:00:00Z'); + + expect( + executeDomainKeyword('autoIncrement.timestamp', { + args: ['2026-06-12T16:00:00Z', 1, 'hours'], + autoIncrementState: state, + }) + ).toBe('2026-06-12T17:00:00Z'); + + expect( + executeDomainKeyword('autoIncrement.timestamp', { + args: ['2026-06-12T16:00:00Z', 1, 'hours'], + autoIncrementState: state, + }) + ).toBe('2026-06-12T18:00:00Z'); + }); }); diff --git a/packages/core/src/tests/mcp/anywaydata-mcp-contract.test.js b/packages/core/src/tests/mcp/anywaydata-mcp-contract.test.js index 50684cf0..f957acac 100644 --- a/packages/core/src/tests/mcp/anywaydata-mcp-contract.test.js +++ b/packages/core/src/tests/mcp/anywaydata-mcp-contract.test.js @@ -27,6 +27,17 @@ describe('AnyWayData MCP contract', () => { expect(result.format).toBe('json'); }); + test('executes domain keyword generation through the shared contract', () => { + const result = executeAnyWayDataMcpTool('generate_data_from_spec', { + textSpec: 't2\nautoIncrement.timestamp(start="12th June 2026 at 4pm", step=60, type="minutes")', + rowCount: 3, + outputFormat: 'json', + }); + + expect(result.ok).toBe(true); + expect(result.rows).toEqual([['2026-06-12T16:00:00Z'], ['2026-06-12T17:00:00Z'], ['2026-06-12T18:00:00Z']]); + }); + test('describes flat formatter options on generation tools', () => { const generateTool = listAnyWayDataMcpTools().find((tool) => tool.name === 'generate_data_from_spec'); const amendTool = listAnyWayDataMcpTools().find((tool) => tool.name === 'amend_data_from_spec'); From 78e9dbf5438d35dc1da37af900621abf25ec56f6 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sat, 13 Jun 2026 18:11:56 +0100 Subject: [PATCH 3/7] Prevent domain execution context overrides --- .../domain/domainTestDataGenerator.js | 2 +- .../domainTestDataGenerator-state.test.js | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/core/js/data_generation/domain/domainTestDataGenerator.js b/packages/core/js/data_generation/domain/domainTestDataGenerator.js index dd7efef4..cf63ddbf 100644 --- a/packages/core/js/data_generation/domain/domainTestDataGenerator.js +++ b/packages/core/js/data_generation/domain/domainTestDataGenerator.js @@ -19,9 +19,9 @@ class DomainTestDataGenerator { try { const result = executeDomainKeyword(parsed.keyword, { + ...executionContext, faker: this.faker, args: Array.isArray(parsed.args) ? parsed.args : [], - ...executionContext, autoIncrementState: this.#getAutoIncrementStateForRule(aRule), }); return dataResponse(result); diff --git a/packages/core/src/tests/data_generation/unit/domain/domainTestDataGenerator-state.test.js b/packages/core/src/tests/data_generation/unit/domain/domainTestDataGenerator-state.test.js index 6216a4d5..32398e8b 100644 --- a/packages/core/src/tests/data_generation/unit/domain/domainTestDataGenerator-state.test.js +++ b/packages/core/src/tests/data_generation/unit/domain/domainTestDataGenerator-state.test.js @@ -15,4 +15,40 @@ describe('DomainTestDataGenerator state restore', () => { expect(generator.generateFrom(secondRule).data).toBe(100); }); + + test('uses the generator faker instance instead of an execution-context override', () => { + const generatorFaker = { + person: { + firstName: () => 'generator-faker', + }, + }; + const overrideFaker = { + person: { + firstName: () => 'override-faker', + }, + }; + const generator = new DomainTestDataGenerator(generatorFaker); + + const result = generator.generateFrom( + { ruleSpec: 'person.firstName' }, + { + faker: overrideFaker, + } + ); + + expect(result.data).toBe('generator-faker'); + }); + + test('uses parsed rule args instead of execution-context args overrides', () => { + const generator = new DomainTestDataGenerator(faker); + + const result = generator.generateFrom( + { ruleSpec: 'literal.value("from-rule")' }, + { + args: ['from-context'], + } + ); + + expect(result.data).toBe('from-rule'); + }); }); From 7a2e651ed5c9dbc970aefc74f5c02ac376b8b8dd Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sat, 13 Jun 2026 18:18:20 +0100 Subject: [PATCH 4/7] Clarify schema definition examples --- docs-src/docs/040-test-data/018-Schema-Definition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-src/docs/040-test-data/018-Schema-Definition.md b/docs-src/docs/040-test-data/018-Schema-Definition.md index 3bae5b95..1ee8296b 100644 --- a/docs-src/docs/040-test-data/018-Schema-Definition.md +++ b/docs-src/docs/040-test-data/018-Schema-Definition.md @@ -96,7 +96,7 @@ CreatedAt autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=1, type="seconds") ``` -This generates names such as `Alice Smith`, or deterministic timestamps when you need time-ordered rows. +The `Customer Name` example generates names such as `Alice Smith`. The `CreatedAt` example generates deterministic timestamps for time-ordered rows. ## Comments and blank lines From e4c0d82211b90eea9486a46a008b849abebe5030 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sat, 13 Jun 2026 18:25:26 +0100 Subject: [PATCH 5/7] Restore CI API test parallelism --- playwright-api.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright-api.config.js b/playwright-api.config.js index 9e7ca3c3..4537ccc1 100644 --- a/playwright-api.config.js +++ b/playwright-api.config.js @@ -8,7 +8,7 @@ module.exports = defineConfig({ testDir: './apps/api/src/tests', testMatch: '**/*.spec.js', timeout: 30000, - workers: 1, + workers: process.env.CI ? 6 : 1, expect: { timeout: 15000, }, From a9462c2af4872f239b14e7ae44fa709de17ab7f2 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sat, 13 Jun 2026 18:44:52 +0100 Subject: [PATCH 6/7] Allow Vitest browser port fallback --- vitest.config.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/vitest.config.mjs b/vitest.config.mjs index b2876af1..8621cddd 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -30,7 +30,6 @@ export default defineConfig({ api: { host: '127.0.0.1', port: 51315, - strictPort: true, }, instances: [{ browser: 'chromium' }], }, From 13f4c8f77176f3370a61b6824a55920d0a6a09a5 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sat, 13 Jun 2026 23:24:33 +0100 Subject: [PATCH 7/7] Accept zero row index for timestamp increments --- packages/core/js/domain/auto-increment-timestamp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/js/domain/auto-increment-timestamp.js b/packages/core/js/domain/auto-increment-timestamp.js index a2565664..c62b9ca2 100644 --- a/packages/core/js/domain/auto-increment-timestamp.js +++ b/packages/core/js/domain/auto-increment-timestamp.js @@ -176,7 +176,7 @@ function executeCustomAutoIncrementTimestamp(executionContext = {}) { const inputFormatArg = args[4]; const state = executionContext.autoIncrementState || null; const rowIndex = - Number.isInteger(executionContext.rowIndex) && executionContext.rowIndex > 0 ? executionContext.rowIndex : 0; + Number.isInteger(executionContext.rowIndex) && executionContext.rowIndex >= 0 ? executionContext.rowIndex : 0; const now = resolveNow(executionContext); const startDate = resolveStartDate(startArg, inputFormatArg, now); const stepValue = typeof stepArg === 'undefined' ? DEFAULT_STEP_VALUE : getNumericArg(stepArg, 'step');