create-appraisejs 0.3.1-alpha.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/package.json +1 -1
  2. package/templates/blank/.env.example +2 -2
  3. package/templates/blank/.prettierrc +13 -13
  4. package/templates/blank/cucumber.mjs +12 -1
  5. package/templates/blank/e2e/helpers/test-data.ts +10 -0
  6. package/templates/blank/next-env.d.ts +6 -6
  7. package/templates/blank/package-lock.json +2 -2
  8. package/templates/blank/package.json +1 -1
  9. package/templates/blank/packages/cucumber-runtime/package.json +2 -1
  10. package/templates/blank/packages/cucumber-runtime/src/paths.ts +22 -5
  11. package/templates/blank/packages/locator-picker-companion/package.json +2 -1
  12. package/templates/blank/prisma/dev.db +0 -0
  13. package/templates/blank/prisma/migrations/20251104113456_add_type_for_template_step_groups/migration.sql +16 -16
  14. package/templates/blank/prisma/migrations/20251104170946_add_tags_to_test_suite_and_test_case/migration.sql +27 -27
  15. package/templates/blank/prisma/migrations/20251112190024_add_cascade_delete_to_test_run_test_case/migration.sql +17 -17
  16. package/templates/blank/prisma/migrations/20251113181100_add_test_run_log/migration.sql +12 -12
  17. package/templates/blank/prisma/migrations/20251119191838_add_tag_type/migration.sql +28 -28
  18. package/templates/blank/prisma/migrations/20251121164059_add_conflict_resolution/migration.sql +12 -12
  19. package/templates/blank/prisma/migrations/20251223183400_add_report_model_to_db_schema/migration.sql +10 -10
  20. package/templates/blank/prisma/migrations/20251223183637_add_report_test_case_entity_for_storing_test_results_for_individual_test_cases/migration.sql +10 -10
  21. package/templates/blank/prisma/migrations/20251224083549_add_comprehensive_report_storage/migration.sql +108 -108
  22. package/templates/blank/prisma/migrations/20251229194422_migrate_duration_to_string/migration.sql +55 -55
  23. package/templates/blank/prisma/migrations/20251230124637_add_unique_constraint_to_test_run_name/migration.sql +27 -27
  24. package/templates/blank/prisma/migrations/20260115094436_add_dashboard_metrics/migration.sql +59 -59
  25. package/templates/blank/prisma/migrations/20260127172022_add_cascade_delete_to_step_parameters/migration.sql +34 -34
  26. package/templates/blank/prisma/migrations/20260313093000_add_report_step_screenshot_path/migration.sql +1 -1
  27. package/templates/blank/scripts/setup-env.ts +0 -0
  28. package/templates/blank/scripts/sync-test-cases.ts +60 -10
  29. package/templates/blank/src/components/diagram/flow-diagram-node-search.tsx +9 -2
  30. package/templates/blank/src/components/diagram/flow-diagram-toolbar.tsx +37 -3
  31. package/templates/blank/src/components/diagram/flow-diagram.test.tsx +225 -0
  32. package/templates/blank/src/components/diagram/use-flow-diagram-search.ts +2 -0
  33. package/templates/blank/src/components/diagram/use-flow-diagram.ts +93 -0
  34. package/templates/blank/src/lib/appraise-test-case-metadata.test.ts +78 -0
  35. package/templates/blank/src/lib/appraise-test-case-metadata.ts +220 -0
  36. package/templates/blank/src/lib/automation/automation-path-roots.test.ts +14 -0
  37. package/templates/blank/src/lib/automation/automation-path-roots.ts +10 -2
  38. package/templates/blank/src/lib/database-sync.ts +166 -15
  39. package/templates/blank/src/lib/executor/local-executor-adapter.ts +6 -2
  40. package/templates/blank/src/lib/feature-file-generator.ts +54 -10
  41. package/templates/blank/src/lib/gherkin-parser.test.ts +52 -0
  42. package/templates/blank/src/lib/gherkin-parser.ts +39 -1
  43. package/templates/blank/src/lib/sync/projected-feature-utils.ts +5 -1
  44. package/templates/blank/src/lib/sync/sync-pending-counts.test.ts +115 -0
  45. package/templates/blank/src/lib/sync/sync-pending-counts.ts +108 -13
  46. package/templates/blank/src/services/test-run/test-run-service.test.ts +10 -0
  47. package/templates/blank/src/services/test-run/test-run-service.ts +41 -1
  48. package/templates/starter/.env.example +2 -2
  49. package/templates/starter/.prettierrc +13 -13
  50. package/templates/starter/cucumber.mjs +12 -1
  51. package/templates/starter/e2e/helpers/test-data.ts +10 -0
  52. package/templates/starter/next-env.d.ts +6 -6
  53. package/templates/starter/package-lock.json +2 -2
  54. package/templates/starter/package.json +1 -1
  55. package/templates/starter/packages/cucumber-runtime/package.json +2 -1
  56. package/templates/starter/packages/cucumber-runtime/src/paths.ts +22 -5
  57. package/templates/starter/packages/locator-picker-companion/package.json +2 -1
  58. package/templates/starter/prisma/dev.db +0 -0
  59. package/templates/starter/prisma/migrations/20251104113456_add_type_for_template_step_groups/migration.sql +16 -16
  60. package/templates/starter/prisma/migrations/20251104170946_add_tags_to_test_suite_and_test_case/migration.sql +27 -27
  61. package/templates/starter/prisma/migrations/20251112190024_add_cascade_delete_to_test_run_test_case/migration.sql +17 -17
  62. package/templates/starter/prisma/migrations/20251113181100_add_test_run_log/migration.sql +12 -12
  63. package/templates/starter/prisma/migrations/20251119191838_add_tag_type/migration.sql +28 -28
  64. package/templates/starter/prisma/migrations/20251121164059_add_conflict_resolution/migration.sql +12 -12
  65. package/templates/starter/prisma/migrations/20251223183400_add_report_model_to_db_schema/migration.sql +10 -10
  66. package/templates/starter/prisma/migrations/20251223183637_add_report_test_case_entity_for_storing_test_results_for_individual_test_cases/migration.sql +10 -10
  67. package/templates/starter/prisma/migrations/20251224083549_add_comprehensive_report_storage/migration.sql +108 -108
  68. package/templates/starter/prisma/migrations/20251229194422_migrate_duration_to_string/migration.sql +55 -55
  69. package/templates/starter/prisma/migrations/20251230124637_add_unique_constraint_to_test_run_name/migration.sql +27 -27
  70. package/templates/starter/prisma/migrations/20260115094436_add_dashboard_metrics/migration.sql +59 -59
  71. package/templates/starter/prisma/migrations/20260127172022_add_cascade_delete_to_step_parameters/migration.sql +34 -34
  72. package/templates/starter/prisma/migrations/20260313093000_add_report_step_screenshot_path/migration.sql +1 -1
  73. package/templates/starter/scripts/setup-env.ts +0 -0
  74. package/templates/starter/scripts/sync-test-cases.ts +60 -10
  75. package/templates/starter/src/components/diagram/flow-diagram-node-search.tsx +9 -2
  76. package/templates/starter/src/components/diagram/flow-diagram-toolbar.tsx +37 -3
  77. package/templates/starter/src/components/diagram/flow-diagram.test.tsx +225 -0
  78. package/templates/starter/src/components/diagram/use-flow-diagram-search.ts +2 -0
  79. package/templates/starter/src/components/diagram/use-flow-diagram.ts +93 -0
  80. package/templates/starter/src/lib/appraise-test-case-metadata.test.ts +78 -0
  81. package/templates/starter/src/lib/appraise-test-case-metadata.ts +220 -0
  82. package/templates/starter/src/lib/automation/automation-path-roots.test.ts +14 -0
  83. package/templates/starter/src/lib/automation/automation-path-roots.ts +10 -2
  84. package/templates/starter/src/lib/database-sync.ts +166 -15
  85. package/templates/starter/src/lib/executor/local-executor-adapter.ts +6 -2
  86. package/templates/starter/src/lib/feature-file-generator.ts +54 -10
  87. package/templates/starter/src/lib/gherkin-parser.test.ts +52 -0
  88. package/templates/starter/src/lib/gherkin-parser.ts +39 -1
  89. package/templates/starter/src/lib/sync/projected-feature-utils.ts +5 -1
  90. package/templates/starter/src/lib/sync/sync-pending-counts.test.ts +115 -0
  91. package/templates/starter/src/lib/sync/sync-pending-counts.ts +108 -13
  92. package/templates/starter/src/services/test-run/test-run-service.test.ts +10 -0
  93. package/templates/starter/src/services/test-run/test-run-service.ts +41 -1
  94. package/dist/cli.e2e.test.d.ts +0 -2
  95. package/dist/cli.e2e.test.d.ts.map +0 -1
  96. package/dist/cli.e2e.test.js +0 -75
  97. package/dist/cli.e2e.test.js.map +0 -1
  98. package/dist/config.test.d.ts +0 -2
  99. package/dist/config.test.d.ts.map +0 -1
  100. package/dist/config.test.js +0 -65
  101. package/dist/config.test.js.map +0 -1
  102. package/dist/copy-template.test.d.ts +0 -2
  103. package/dist/copy-template.test.d.ts.map +0 -1
  104. package/dist/copy-template.test.js +0 -71
  105. package/dist/copy-template.test.js.map +0 -1
  106. package/dist/download-repo.test.d.ts +0 -2
  107. package/dist/download-repo.test.d.ts.map +0 -1
  108. package/dist/download-repo.test.js +0 -16
  109. package/dist/download-repo.test.js.map +0 -1
  110. package/dist/install.test.d.ts +0 -2
  111. package/dist/install.test.d.ts.map +0 -1
  112. package/dist/install.test.js +0 -120
  113. package/dist/install.test.js.map +0 -1
  114. package/dist/prompts.test.d.ts +0 -2
  115. package/dist/prompts.test.d.ts.map +0 -1
  116. package/dist/prompts.test.js +0 -58
  117. package/dist/prompts.test.js.map +0 -1
  118. package/templates/default/next-env.d.ts +0 -6
  119. package/templates/default/packages/locator-picker-companion/dist/cli.d.ts +0 -1
  120. package/templates/default/packages/locator-picker-companion/dist/cli.js +0 -336
  121. package/templates/default/packages/locator-picker-companion/dist/index.d.ts +0 -3
  122. package/templates/default/packages/locator-picker-companion/dist/index.js +0 -3
  123. package/templates/default/packages/locator-picker-companion/dist/injected-picker-script.d.ts +0 -1
  124. package/templates/default/packages/locator-picker-companion/dist/injected-picker-script.js +0 -660
  125. package/templates/default/packages/locator-picker-companion/dist/launcher.d.ts +0 -14
  126. package/templates/default/packages/locator-picker-companion/dist/launcher.js +0 -58
  127. package/templates/default/packages/locator-picker-companion/dist/selector-generator.d.ts +0 -6
  128. package/templates/default/packages/locator-picker-companion/dist/selector-generator.js +0 -261
  129. package/templates/default/packages/locator-picker-companion/dist/session-file.d.ts +0 -30
  130. package/templates/default/packages/locator-picker-companion/dist/session-file.js +0 -162
  131. package/templates/default/packages/locator-picker-companion/dist/types.d.ts +0 -31
  132. package/templates/default/packages/locator-picker-companion/dist/types.js +0 -1
  133. package/templates/default/prisma/dev.db +0 -0
@@ -4,6 +4,7 @@ import prisma from '@/config/db-config'
4
4
  import { buildModulePath } from '@/lib/path-helpers/module-path'
5
5
  import { getAutomationFeaturesDir } from '@/lib/automation/automation-path-roots'
6
6
  import { ensureAutomationWorkspaceReady } from '@/lib/automation/automation-workspace'
7
+ import { buildAppraiseMetadata, getAppraiseMetadataPath } from '@/lib/appraise-test-case-metadata'
7
8
  import { generateProjectedGherkinSteps, getTestSuiteFilesystemKey } from '@/lib/sync/projected-feature-utils'
8
9
 
9
10
  async function isDirectoryEmpty(dirPath: string): Promise<boolean> {
@@ -32,6 +33,18 @@ async function removeEmptyDirectoriesUp(dirPath: string, basePath: string): Prom
32
33
  }
33
34
  }
34
35
 
36
+ async function unlinkIfExists(filePath: string): Promise<boolean> {
37
+ try {
38
+ await fs.unlink(filePath)
39
+ return true
40
+ } catch (error: unknown) {
41
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
42
+ return false
43
+ }
44
+ throw error
45
+ }
46
+ }
47
+
35
48
  export async function generateFeatureFile(
36
49
  testSuiteId: string,
37
50
  testSuiteName: string,
@@ -53,6 +66,14 @@ export async function generateFeatureFile(
53
66
  order: 'asc',
54
67
  },
55
68
  },
69
+ flowBlocks: {
70
+ include: {
71
+ nodes: true,
72
+ },
73
+ orderBy: {
74
+ order: 'asc',
75
+ },
76
+ },
56
77
  tags: true,
57
78
  },
58
79
  },
@@ -73,6 +94,15 @@ export async function generateFeatureFile(
73
94
  testSuite.testCases,
74
95
  testSuite.tags,
75
96
  )
97
+ const metadataContent = JSON.stringify(
98
+ buildAppraiseMetadata({
99
+ testSuiteName,
100
+ modulePath,
101
+ testCases: testSuite.testCases,
102
+ }),
103
+ null,
104
+ 2,
105
+ )
76
106
 
77
107
  const featuresBaseDir = getAutomationFeaturesDir()
78
108
  const moduleDir = join(featuresBaseDir, modulePath.substring(1))
@@ -82,6 +112,7 @@ export async function generateFeatureFile(
82
112
  const featureFilePath = join(moduleDir, `${safeFileName}.feature`)
83
113
 
84
114
  await fs.writeFile(featureFilePath, featureContent, 'utf8')
115
+ await fs.writeFile(getAppraiseMetadataPath(featureFilePath), `${metadataContent}\n`, 'utf8')
85
116
  return featureFilePath
86
117
  } catch (error) {
87
118
  console.error('Error generating feature file:', error)
@@ -178,17 +209,12 @@ export async function deleteFeatureFile(testSuiteId: string): Promise<boolean> {
178
209
  const featuresBaseDir = getAutomationFeaturesDir()
179
210
  const moduleDir = join(featuresBaseDir, modulePath.substring(1))
180
211
  const featureFilePath = join(moduleDir, `${safeFileName}.feature`)
212
+ const metadataFilePath = getAppraiseMetadataPath(featureFilePath)
181
213
 
182
- try {
183
- await fs.unlink(featureFilePath)
184
- await removeEmptyDirectoriesUp(moduleDir, featuresBaseDir)
185
- return true
186
- } catch (error: unknown) {
187
- if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
188
- return false
189
- }
190
- throw error
191
- }
214
+ const deletedFeature = await unlinkIfExists(featureFilePath)
215
+ const deletedMetadata = await unlinkIfExists(metadataFilePath)
216
+ await removeEmptyDirectoriesUp(moduleDir, featuresBaseDir)
217
+ return deletedFeature || deletedMetadata
192
218
  } catch (error) {
193
219
  console.error('Error deleting feature file:', error)
194
220
  throw error
@@ -220,6 +246,14 @@ export async function regenerateAllFeatureFiles(): Promise<string[]> {
220
246
  order: 'asc',
221
247
  },
222
248
  },
249
+ flowBlocks: {
250
+ include: {
251
+ nodes: true,
252
+ },
253
+ orderBy: {
254
+ order: 'asc',
255
+ },
256
+ },
223
257
  tags: true,
224
258
  },
225
259
  },
@@ -239,6 +273,15 @@ export async function regenerateAllFeatureFiles(): Promise<string[]> {
239
273
  testSuite.testCases,
240
274
  testSuite.tags,
241
275
  )
276
+ const metadataContent = JSON.stringify(
277
+ buildAppraiseMetadata({
278
+ testSuiteName: testSuite.name,
279
+ modulePath,
280
+ testCases: testSuite.testCases,
281
+ }),
282
+ null,
283
+ 2,
284
+ )
242
285
 
243
286
  const moduleDir = join(featuresBaseDir, modulePath.substring(1))
244
287
  await fs.mkdir(moduleDir, { recursive: true })
@@ -247,6 +290,7 @@ export async function regenerateAllFeatureFiles(): Promise<string[]> {
247
290
  const featureFilePath = join(moduleDir, `${safeFileName}.feature`)
248
291
 
249
292
  await fs.writeFile(featureFilePath, featureContent, 'utf8')
293
+ await fs.writeFile(getAppraiseMetadataPath(featureFilePath), `${metadataContent}\n`, 'utf8')
250
294
  generatedFiles.push(featureFilePath)
251
295
  } catch (error) {
252
296
  console.error(`Error generating feature file for test suite ${testSuite.name}:`, error)
@@ -3,6 +3,7 @@ import { join } from 'path'
3
3
  import { tmpdir } from 'os'
4
4
  import { expect, test } from 'vitest'
5
5
 
6
+ import { getAppraiseMetadataPath } from '@/lib/appraise-test-case-metadata'
6
7
  import { parseFeatureFile } from '@/lib/gherkin-parser'
7
8
 
8
9
  async function withTempFeatureFile(content: string): Promise<string> {
@@ -42,3 +43,54 @@ Scenario: buys item
42
43
  expect(parsed).not.toBeNull()
43
44
  expect(parsed?.featureDescription).toBe('Checkout flow')
44
45
  })
46
+
47
+ test('attaches adjacent Appraise metadata to matching scenarios', async () => {
48
+ const filePath = await withTempFeatureFile(`
49
+ Feature: Checkout flow
50
+
51
+ @tc_checkout_buy @smoke
52
+ Scenario: [Legacy description] Legacy title
53
+ Given user adds item to cart
54
+ `)
55
+ await fs.writeFile(
56
+ getAppraiseMetadataPath(filePath),
57
+ JSON.stringify({
58
+ version: 1,
59
+ testSuite: { name: 'checkout-flow', modulePath: '/checkout' },
60
+ testCases: [
61
+ {
62
+ identifierTag: '@tc_checkout_buy',
63
+ title: 'Buys item',
64
+ description: 'Happy path',
65
+ nodes: [{ nodeId: 'node-cart', order: 1, label: 'Add item' }],
66
+ flowBlocks: [{ id: 'block-cart', name: 'Cart', order: 0, nodeIds: ['node-cart'] }],
67
+ },
68
+ ],
69
+ }),
70
+ 'utf8',
71
+ )
72
+
73
+ const parsed = await parseFeatureFile(filePath)
74
+
75
+ expect(parsed?.scenarios[0]?.appraiseMetadata).toMatchObject({
76
+ title: 'Buys item',
77
+ description: 'Happy path',
78
+ nodes: [{ nodeId: 'node-cart', order: 1, label: 'Add item' }],
79
+ })
80
+ })
81
+
82
+ test('falls back to feature-only parsing when sidecar is malformed', async () => {
83
+ const filePath = await withTempFeatureFile(`
84
+ Feature: Checkout flow
85
+
86
+ @tc_checkout_buy
87
+ Scenario: buys item
88
+ Given user adds item to cart
89
+ `)
90
+ await fs.writeFile(getAppraiseMetadataPath(filePath), '{', 'utf8')
91
+
92
+ const parsed = await parseFeatureFile(filePath)
93
+
94
+ expect(parsed?.scenarios[0]?.appraiseMetadata).toBeUndefined()
95
+ expect(parsed?.metadataWarnings).toHaveLength(1)
96
+ })
@@ -1,5 +1,11 @@
1
1
  import { promises as fs } from 'fs'
2
2
  import { join } from 'path'
3
+ import {
4
+ getAppraiseMetadataPath,
5
+ getMetadataByIdentifier,
6
+ readAppraiseMetadataFile,
7
+ type AppraiseTestCaseMetadataEntry,
8
+ } from '@/lib/appraise-test-case-metadata'
3
9
  import { getFeatureModulePath } from '@/lib/path-helpers/feature-path'
4
10
 
5
11
  /**
@@ -11,6 +17,7 @@ export interface ParsedFeature {
11
17
  featureDescription?: string
12
18
  tags: string[]
13
19
  scenarios: ParsedScenario[]
20
+ metadataWarnings: string[]
14
21
  }
15
22
 
16
23
  /**
@@ -21,6 +28,7 @@ export interface ParsedScenario {
21
28
  description?: string
22
29
  tags: string[]
23
30
  steps: ParsedStep[]
31
+ appraiseMetadata?: AppraiseTestCaseMetadataEntry
24
32
  }
25
33
 
26
34
  /**
@@ -30,6 +38,7 @@ export interface ParsedStep {
30
38
  keyword: string
31
39
  text: string
32
40
  order: number
41
+ appraiseNode?: AppraiseTestCaseMetadataEntry['nodes'][number]
33
42
  }
34
43
 
35
44
  const STEP_KEYWORDS = ['Given', 'When', 'Then', 'And', 'But'] as const
@@ -103,6 +112,23 @@ function parseFeatureLine(line: string) {
103
112
  return line.replace('Feature:', '').trim()
104
113
  }
105
114
 
115
+ function normalizeTagExpression(tagExpression: string): string {
116
+ return tagExpression.startsWith('@') ? tagExpression : `@${tagExpression}`
117
+ }
118
+
119
+ function getScenarioIdentifierTag(scenario: ParsedScenario): string | null {
120
+ for (const tagLine of scenario.tags) {
121
+ const tags = tagLine.split(/\s+/).filter(tag => tag.trim().startsWith('@'))
122
+ const identifierTag = tags.find(tag => normalizeTagExpression(tag).replace(/^@/, '').startsWith('tc_'))
123
+
124
+ if (identifierTag) {
125
+ return normalizeTagExpression(identifierTag)
126
+ }
127
+ }
128
+
129
+ return null
130
+ }
131
+
106
132
  function startScenario(lines: string[], index: number): ParsedScenario {
107
133
  const { name, description } = parseScenarioHeader(lines[index])
108
134
 
@@ -170,10 +196,21 @@ function parseGherkinLines(lines: string[]) {
170
196
  */
171
197
  export async function parseFeatureFile(filePath: string): Promise<ParsedFeature | null> {
172
198
  try {
173
- const content = await fs.readFile(filePath, 'utf-8')
199
+ const [content, metadataResult] = await Promise.all([
200
+ fs.readFile(filePath, 'utf-8'),
201
+ readAppraiseMetadataFile(getAppraiseMetadataPath(filePath)),
202
+ ])
174
203
  const lines = normalizeGherkinLines(content)
175
204
  const featureTags = getFeatureTags(lines)
176
205
  const { featureName, featureDescription, scenarios } = parseGherkinLines(lines)
206
+ const metadataByIdentifier = getMetadataByIdentifier(metadataResult.metadata)
207
+
208
+ for (const scenario of scenarios) {
209
+ const identifierTag = getScenarioIdentifierTag(scenario)
210
+ if (identifierTag) {
211
+ scenario.appraiseMetadata = metadataByIdentifier.get(identifierTag)
212
+ }
213
+ }
177
214
 
178
215
  if (!featureName) {
179
216
  console.warn(`No feature found in file: ${filePath}`)
@@ -186,6 +223,7 @@ export async function parseFeatureFile(filePath: string): Promise<ParsedFeature
186
223
  featureDescription: featureDescription || undefined,
187
224
  tags: featureTags,
188
225
  scenarios,
226
+ metadataWarnings: metadataResult.warnings,
189
227
  }
190
228
  } catch (error) {
191
229
  console.error(`Error parsing feature file ${filePath}:`, error)
@@ -7,6 +7,8 @@ type StoredProjectedStep = {
7
7
  }
8
8
 
9
9
  type StoredProjectedDbStep = StoredProjectedStep & {
10
+ flowNodeId: string | null
11
+ label: string
10
12
  TemplateStep: { signature: string } | null
11
13
  parameters: Array<{ name: string; value: string; order: number; type: StepParameterType }>
12
14
  }
@@ -16,6 +18,7 @@ export type ProjectedDbTestCaseStep = {
16
18
  keyword: string
17
19
  text: string
18
20
  gherkinStep: string
21
+ flowNodeId: string | null
19
22
  label: string
20
23
  icon: TemplateStepIcon
21
24
  templateStepSignature: string | null
@@ -64,7 +67,8 @@ export function normalizeProjectedDbTestCaseSteps(steps: StoredProjectedDbStep[]
64
67
  keyword,
65
68
  text,
66
69
  gherkinStep,
67
- label: text,
70
+ flowNodeId: step.flowNodeId,
71
+ label: step.label,
68
72
  icon: determineProjectedStepIcon(keyword),
69
73
  templateStepSignature: step.TemplateStep?.signature ?? null,
70
74
  parameters: step.parameters,
@@ -342,6 +342,53 @@ describe('sync pending counts', () => {
342
342
  expect(count).toBe(0)
343
343
  })
344
344
 
345
+ it('counts sidecar-backed node label and flow block mismatches', () => {
346
+ const baseFilesystemCase = {
347
+ identifierTag: '@tc_checkout',
348
+ title: 'Checkout',
349
+ description: 'Buys an item',
350
+ testSuiteName: 'checkout-suite',
351
+ modulePath: '/commerce',
352
+ filterTags: [],
353
+ steps: [{ order: 1, keyword: 'Given' as const, text: 'open checkout' }],
354
+ hasAppraiseMetadata: true,
355
+ nodes: [{ nodeId: 'node-open', order: 1, label: 'Open checkout' }],
356
+ flowBlocks: [{ id: 'block-flow', name: 'Checkout flow', order: 0, nodeIds: ['node-open'] }],
357
+ }
358
+
359
+ const dbCase = {
360
+ title: 'Checkout',
361
+ description: 'Buys an item',
362
+ tags: [{ tagExpression: '@tc_checkout', type: TagType.IDENTIFIER }],
363
+ TestSuite: [{ name: 'Checkout Suite', moduleId: 'module-commerce' }],
364
+ steps: [
365
+ {
366
+ order: 1,
367
+ gherkinStep: 'When open checkout',
368
+ flowNodeId: 'node-open',
369
+ label: 'Old label',
370
+ icon: TemplateStepIcon.MOUSE,
371
+ TemplateStep: { signature: 'open checkout' },
372
+ parameters: [],
373
+ },
374
+ ],
375
+ flowBlocks: [
376
+ {
377
+ id: 'block-flow',
378
+ name: 'Checkout flow',
379
+ order: 0,
380
+ nodes: [{ flowNodeId: 'node-other' }],
381
+ },
382
+ ],
383
+ }
384
+
385
+ const count = countTestCaseMismatches([baseFilesystemCase], [dbCase], new Map([['module-commerce', '/commerce']]), [
386
+ { signature: 'open checkout', parameters: [] },
387
+ ])
388
+
389
+ expect(count).toBe(1)
390
+ })
391
+
345
392
  it('ignores duplicate stale DB test cases when one matching identifier row exists', () => {
346
393
  const count = countTestCaseMismatches(
347
394
  [
@@ -402,6 +449,74 @@ describe('sync pending counts', () => {
402
449
  expect(count).toBe(0)
403
450
  })
404
451
 
452
+ it('treats And steps after Then as in sync when DB stores the feature-file keyword', () => {
453
+ const count = countTestCaseMismatches(
454
+ [
455
+ {
456
+ identifierTag: '@tc_route',
457
+ title: 'Route Check',
458
+ description: 'Validates route after login',
459
+ testSuiteName: 'authentication',
460
+ modulePath: '/E2E Auth',
461
+ filterTags: [],
462
+ steps: [
463
+ { order: 1, keyword: 'Given', text: 'open the login page' },
464
+ { order: 2, keyword: 'Then', text: 'the url route should be equal to "/home"' },
465
+ { order: 3, keyword: 'And', text: 'the page title should be "Home"' },
466
+ ],
467
+ },
468
+ ],
469
+ [
470
+ {
471
+ title: 'Route Check',
472
+ description: 'Validates route after login',
473
+ tags: [{ tagExpression: '@tc_route', type: TagType.IDENTIFIER }],
474
+ TestSuite: [{ name: 'Authentication', moduleId: 'module-auth' }],
475
+ steps: [
476
+ {
477
+ order: 1,
478
+ gherkinStep: 'Given open the login page',
479
+ label: 'open the login page',
480
+ icon: TemplateStepIcon.NAVIGATION,
481
+ TemplateStep: { signature: 'open the login page' },
482
+ parameters: [],
483
+ },
484
+ {
485
+ order: 2,
486
+ gherkinStep: 'Then the url route should be equal to "/home"',
487
+ label: 'the url route should be equal to "/home"',
488
+ icon: TemplateStepIcon.VALIDATION,
489
+ TemplateStep: { signature: 'the url route should be equal to {string}' },
490
+ parameters: [{ name: 'route', value: '/home', order: 0, type: StepParameterType.STRING }],
491
+ },
492
+ {
493
+ order: 3,
494
+ gherkinStep: 'And the page title should be "Home"',
495
+ label: 'the page title should be "Home"',
496
+ icon: TemplateStepIcon.MOUSE,
497
+ TemplateStep: { signature: 'the page title should be {string}' },
498
+ parameters: [{ name: 'title', value: 'Home', order: 0, type: StepParameterType.STRING }],
499
+ },
500
+ ],
501
+ },
502
+ ],
503
+ new Map([['module-auth', '/E2E Auth']]),
504
+ [
505
+ { signature: 'open the login page', parameters: [] },
506
+ {
507
+ signature: 'the url route should be equal to {string}',
508
+ parameters: [{ name: 'route', order: 0, type: StepParameterType.STRING }],
509
+ },
510
+ {
511
+ signature: 'the page title should be {string}',
512
+ parameters: [{ name: 'title', order: 0, type: StepParameterType.STRING }],
513
+ },
514
+ ],
515
+ )
516
+
517
+ expect(count).toBe(0)
518
+ })
519
+
405
520
  it('collapses duplicate filesystem test-case identifiers to the script final state', () => {
406
521
  const count = countTestCaseMismatches(
407
522
  [
@@ -27,9 +27,11 @@ import { getTagTypeFromName } from '@/lib/tag-identifiers'
27
27
  import { extractModulePathFromAutomationFile, getAutomationLocatorMapPath } from '@/lib/template-sync-utils'
28
28
  import {
29
29
  determineProjectedStepIcon,
30
+ generateProjectedGherkinSteps,
30
31
  getTestSuiteSyncIdentity,
31
32
  normalizeProjectedDbTestCaseSteps,
32
33
  } from '@/lib/sync/projected-feature-utils'
34
+ import type { AppraiseTestCaseMetadataFlowBlock, AppraiseTestCaseMetadataNode } from '@/lib/appraise-test-case-metadata'
33
35
  import {
34
36
  parseGroupJSDocLenient as parseGroupJSDoc,
35
37
  parseStepJSDocLenient as parseStepJSDoc,
@@ -116,6 +118,9 @@ type TestCaseFromFs = {
116
118
  modulePath: string
117
119
  filterTags: string[]
118
120
  steps: ParsedStep[]
121
+ hasAppraiseMetadata?: boolean
122
+ nodes: AppraiseTestCaseMetadataNode[]
123
+ flowBlocks: AppraiseTestCaseMetadataFlowBlock[]
119
124
  }
120
125
 
121
126
  type CollapsedTestCaseFromFs = TestCaseFromFs & {
@@ -686,15 +691,18 @@ async function buildFilesystemSnapshot(baseDir: string): Promise<FilesystemSnaps
686
691
  continue
687
692
  }
688
693
 
689
- const { title, description } = parseScenarioTitle(scenario.name, scenario.description)
694
+ const parsedTitle = parseScenarioTitle(scenario.name, scenario.description)
690
695
  testCases.push({
691
696
  identifierTag: normalizeTagExpression(identifierTag),
692
- title,
693
- description,
697
+ title: scenario.appraiseMetadata?.title ?? parsedTitle.title,
698
+ description: scenario.appraiseMetadata?.description ?? parsedTitle.description,
694
699
  testSuiteName,
695
700
  modulePath,
696
701
  filterTags: flattenedTags.filter(tag => normalizeTagExpression(tag) !== normalizeTagExpression(identifierTag)),
697
702
  steps: scenario.steps,
703
+ hasAppraiseMetadata: scenario.appraiseMetadata != null,
704
+ nodes: scenario.appraiseMetadata?.nodes ?? [],
705
+ flowBlocks: scenario.appraiseMetadata?.flowBlocks ?? [],
698
706
  })
699
707
  }
700
708
  }
@@ -987,11 +995,41 @@ export function countTestSuiteMismatches(
987
995
  return count
988
996
  }
989
997
 
998
+ type ProjectedTestCaseStep = {
999
+ order: number
1000
+ gherkinStep: string
1001
+ label: string
1002
+ icon: TemplateStepIcon
1003
+ }
1004
+
1005
+ function normalizeProjectedFsTestCaseSteps(stepsFromFs: ParsedStep[]): ProjectedTestCaseStep[] {
1006
+ const storedSteps = stepsFromFs.map(step => ({
1007
+ order: step.order,
1008
+ gherkinStep: `${step.keyword} ${step.text}`,
1009
+ }))
1010
+ const projectedGherkinSteps = generateProjectedGherkinSteps(storedSteps)
1011
+
1012
+ return stepsFromFs.map((step, index) => {
1013
+ const gherkinStep = projectedGherkinSteps[index] ?? ''
1014
+ const [keyword = '', ...textParts] = gherkinStep.split(' ')
1015
+ const label = textParts.join(' ')
1016
+
1017
+ return {
1018
+ order: step.order,
1019
+ gherkinStep,
1020
+ label,
1021
+ icon: determineProjectedStepIcon(keyword),
1022
+ }
1023
+ })
1024
+ }
1025
+
990
1026
  function hasProjectedTestCaseStepMismatch(
991
1027
  stepsFromFs: ParsedStep[],
1028
+ nodesFromFs: AppraiseTestCaseMetadataNode[] = [],
992
1029
  dbSteps: Array<{
993
1030
  order: number
994
1031
  gherkinStep: string
1032
+ flowNodeId: string | null
995
1033
  label: string
996
1034
  icon: TemplateStepIcon
997
1035
  TemplateStep: { signature: string } | null
@@ -1003,23 +1041,26 @@ function hasProjectedTestCaseStepMismatch(
1003
1041
  }>,
1004
1042
  ): boolean {
1005
1043
  const projectedDbSteps = normalizeProjectedDbTestCaseSteps(dbSteps)
1044
+ const projectedFsSteps = normalizeProjectedFsTestCaseSteps(stepsFromFs)
1006
1045
  const dbStepsByOrder = new Map(projectedDbSteps.map(step => [step.order, step]))
1046
+ const fsStepsByOrder = new Map(stepsFromFs.map(step => [step.order, step]))
1047
+ const nodesByOrder = new Map(nodesFromFs.map(node => [node.order, node]))
1007
1048
 
1008
- for (const step of stepsFromFs) {
1009
- const existing = dbStepsByOrder.get(step.order)
1010
- const matchedTemplateStep = matchGherkinStepToTemplateStep(step, dbTemplateSteps)
1049
+ for (const projectedFsStep of projectedFsSteps) {
1050
+ const existing = dbStepsByOrder.get(projectedFsStep.order)
1051
+ const sourceStep = fsStepsByOrder.get(projectedFsStep.order)
1052
+ const metadataNode = nodesByOrder.get(projectedFsStep.order)
1053
+ const matchedTemplateStep = sourceStep ? matchGherkinStepToTemplateStep(sourceStep, dbTemplateSteps) : null
1011
1054
 
1012
1055
  if (!existing || !matchedTemplateStep) {
1013
1056
  return true
1014
1057
  }
1015
1058
 
1016
- const expectedIcon = determineProjectedStepIcon(step.keyword)
1017
- const expectedGherkinStep = `${step.keyword} ${step.text}`
1018
-
1019
1059
  if (
1020
- existing.gherkinStep !== expectedGherkinStep ||
1021
- existing.label !== step.text ||
1022
- existing.icon !== expectedIcon ||
1060
+ existing.gherkinStep !== projectedFsStep.gherkinStep ||
1061
+ existing.flowNodeId !== (metadataNode?.nodeId ?? existing.flowNodeId) ||
1062
+ existing.label !== (metadataNode?.label ?? projectedFsStep.label) ||
1063
+ existing.icon !== projectedFsStep.icon ||
1023
1064
  existing.templateStepSignature !== matchedTemplateStep.signature ||
1024
1065
  !sameResolvedParameters(existing.parameters, matchedTemplateStep.parameters)
1025
1066
  ) {
@@ -1031,6 +1072,39 @@ function hasProjectedTestCaseStepMismatch(
1031
1072
  return projectedDbSteps.some(step => !fsOrders.has(step.order))
1032
1073
  }
1033
1074
 
1075
+ function hasFlowBlockMismatch(
1076
+ flowBlocksFromFs: AppraiseTestCaseMetadataFlowBlock[] = [],
1077
+ hasAppraiseMetadata: boolean | undefined,
1078
+ dbFlowBlocks: Array<{
1079
+ id: string
1080
+ name: string
1081
+ order: number
1082
+ nodes: Array<{ flowNodeId: string }>
1083
+ }>,
1084
+ ): boolean {
1085
+ if (!hasAppraiseMetadata) {
1086
+ return false
1087
+ }
1088
+
1089
+ if (flowBlocksFromFs.length !== dbFlowBlocks.length) {
1090
+ return true
1091
+ }
1092
+
1093
+ const dbById = new Map(dbFlowBlocks.map(block => [block.id, block]))
1094
+
1095
+ return flowBlocksFromFs.some(block => {
1096
+ const existing = dbById.get(block.id)
1097
+ if (!existing || existing.name !== block.name || existing.order !== block.order) {
1098
+ return true
1099
+ }
1100
+
1101
+ return !sameStringSet(
1102
+ existing.nodes.map(node => node.flowNodeId),
1103
+ block.nodeIds,
1104
+ )
1105
+ })
1106
+ }
1107
+
1034
1108
  export function countTestCaseMismatches(
1035
1109
  filesystemTestCases: TestCaseFromFs[],
1036
1110
  dbTestCases: Array<{
@@ -1041,11 +1115,18 @@ export function countTestCaseMismatches(
1041
1115
  steps: Array<{
1042
1116
  order: number
1043
1117
  gherkinStep: string
1118
+ flowNodeId: string | null
1044
1119
  label: string
1045
1120
  icon: TemplateStepIcon
1046
1121
  TemplateStep: { signature: string } | null
1047
1122
  parameters: Array<{ name: string; value: string; order: number; type: StepParameterType }>
1048
1123
  }>
1124
+ flowBlocks: Array<{
1125
+ id: string
1126
+ name: string
1127
+ order: number
1128
+ nodes: Array<{ flowNodeId: string }>
1129
+ }>
1049
1130
  }>,
1050
1131
  modulePathMap: Map<string, string>,
1051
1132
  dbTemplateSteps: Array<{
@@ -1110,7 +1191,8 @@ export function countTestCaseMismatches(
1110
1191
  existing.description === testCase.description &&
1111
1192
  isLinkedToExpectedSuites &&
1112
1193
  sameStringSet(existingFilterTags, normalizedFilterTags) &&
1113
- !hasProjectedTestCaseStepMismatch(testCase.steps, existing.steps, dbTemplateSteps)
1194
+ !hasProjectedTestCaseStepMismatch(testCase.steps, testCase.nodes, existing.steps, dbTemplateSteps) &&
1195
+ !hasFlowBlockMismatch(testCase.flowBlocks, testCase.hasAppraiseMetadata, existing.flowBlocks ?? [])
1114
1196
  )
1115
1197
  })
1116
1198
 
@@ -1216,6 +1298,7 @@ export async function getSyncPendingCounts(): Promise<SyncPendingCounts> {
1216
1298
  select: {
1217
1299
  order: true,
1218
1300
  gherkinStep: true,
1301
+ flowNodeId: true,
1219
1302
  label: true,
1220
1303
  icon: true,
1221
1304
  TemplateStep: {
@@ -1227,6 +1310,18 @@ export async function getSyncPendingCounts(): Promise<SyncPendingCounts> {
1227
1310
  },
1228
1311
  },
1229
1312
  },
1313
+ flowBlocks: {
1314
+ select: {
1315
+ id: true,
1316
+ name: true,
1317
+ order: true,
1318
+ nodes: {
1319
+ select: {
1320
+ flowNodeId: true,
1321
+ },
1322
+ },
1323
+ },
1324
+ },
1230
1325
  },
1231
1326
  }),
1232
1327
  ])
@@ -39,6 +39,7 @@ const {
39
39
  mockProcessManagerOn,
40
40
  mockProcessManagerRemoveListener,
41
41
  mockFsAccess,
42
+ mockGenerateFeature,
42
43
  } = vi.hoisted(() => ({
43
44
  mockEnvironmentFindUnique: vi.fn(),
44
45
  mockTagFindMany: vi.fn(),
@@ -68,6 +69,7 @@ const {
68
69
  mockProcessManagerOn: vi.fn(),
69
70
  mockProcessManagerRemoveListener: vi.fn(),
70
71
  mockFsAccess: vi.fn(),
72
+ mockGenerateFeature: vi.fn(),
71
73
  }))
72
74
 
73
75
  vi.mock('@/config/db-config', () => ({
@@ -118,6 +120,12 @@ vi.mock('@/lib/test-suite-identifier-service', () => ({
118
120
  ensureTestSuiteIdentifierTags: mockEnsureTestSuiteIdentifierTags,
119
121
  }))
120
122
 
123
+ vi.mock('@/lib/automation/projection-service', () => ({
124
+ automationProjectionService: {
125
+ generateFeature: mockGenerateFeature,
126
+ },
127
+ }))
128
+
121
129
  vi.mock('@/services/report/report-service', () => ({
122
130
  storeReportFromFileService: mockStoreReportFromFileService,
123
131
  }))
@@ -266,6 +274,7 @@ describe('createTestRunFromValidatedValue', () => {
266
274
  error: vi.fn(),
267
275
  })
268
276
  mockGetLogFilePath.mockReturnValue('logs/run-1.log')
277
+ mockGenerateFeature.mockResolvedValue('automation/features/login.feature')
269
278
  mockTestRunUpdate.mockResolvedValue({})
270
279
  mockExecuteTestRun.mockResolvedValue({
271
280
  process: {
@@ -297,6 +306,7 @@ describe('createTestRunFromValidatedValue', () => {
297
306
  const result = await createTestRunFromValidatedValue(baseValue)
298
307
 
299
308
  expect(mockEnsureTestSuiteIdentifierTags).toHaveBeenCalledWith(['suite-1'])
309
+ expect(mockGenerateFeature).toHaveBeenCalledWith('suite-1')
300
310
  expect(mockTestRunCreate).toHaveBeenCalledWith({
301
311
  data: {
302
312
  name: 'Nightly Run',