create-appraisejs 0.2.0-alpha.1 → 0.2.0-alpha.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-appraisejs",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.2.0-alpha.3",
4
4
  "description": "Scaffold a new AppraiseJS app in your directory",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hasnat Jamil",
@@ -44,10 +44,15 @@
44
44
  "sync-templates": "tsx scripts/sync-templates.ts",
45
45
  "test": "vitest run",
46
46
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
47
- "publish": "npm run build && npm publish",
48
- "publish:alpha": "npm run build && npm publish --tag alpha",
49
- "publish:beta": "npm run build && npm publish --tag beta",
50
- "bump:alpha": "npm version prerelease --preid alpha"
47
+ "publish": "npm publish",
48
+ "publish:alpha": "npm publish --tag alpha",
49
+ "publish:beta": "npm publish --tag beta",
50
+ "bump:alpha": "npm version prerelease --preid alpha",
51
+ "bump:beta": "npm version prerelease --preid beta",
52
+ "bump:release": "npm version patch",
53
+ "bump:major": "npm version major",
54
+ "bump:minor": "npm version minor",
55
+ "bump:patch": "npm version patch"
51
56
  },
52
57
  "dependencies": {
53
58
  "@inquirer/prompts": "^7.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "preparedAt": "2026-03-23T05:51:28.888Z",
3
- "inputHash": "538288241ebcec3d1c8efa09eaaba4e5aec5611c00011ab1ec6c2c6c31341fb5",
2
+ "preparedAt": "2026-03-23T20:15:15.041Z",
3
+ "inputHash": "596a28d2c5dc408d39926068711127354f3752a92c086cd0df27e8cf85eb5990",
4
4
  "databasePath": "prisma/dev.db"
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appraise",
3
- "version": "0.1.9-alpha",
3
+ "version": "0.2.0-alpha",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "scripts": {
Binary file
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { promises as fs } from 'fs'
12
+ import path from 'path'
12
13
  import prisma from '../src/config/db-config'
13
14
  import { ensureAutomationWorkspaceReady, getAutomationEnvironmentsDir } from '../src/lib/automation/paths'
14
15
 
@@ -40,16 +41,20 @@ interface SyncResult {
40
41
  skippedEnvironments: string[]
41
42
  }
42
43
 
44
+ const EMPTY_ENVIRONMENTS_FILE_CONTENT = '{}\n'
45
+
43
46
  /**
44
47
  * Reads and parses the environments.json file
45
48
  */
46
49
  async function readEnvironmentsFromFile(): Promise<Record<string, EnvironmentConfig>> {
47
- const filePath = `${getAutomationEnvironmentsDir()}/environments.json`
50
+ const filePath = path.join(getAutomationEnvironmentsDir(), 'environments.json')
48
51
 
49
52
  try {
50
53
  await fs.access(filePath)
51
54
  } catch {
52
- throw new Error(`Environments file not found at ${filePath}`)
55
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
56
+ await fs.writeFile(filePath, EMPTY_ENVIRONMENTS_FILE_CONTENT, 'utf-8')
57
+ return {}
53
58
  }
54
59
 
55
60
  try {
@@ -342,4 +347,3 @@ async function main() {
342
347
  main()
343
348
 
344
349
 
345
-
@@ -156,9 +156,10 @@ export default function RootLayout({
156
156
  },
157
157
  ]}
158
158
  />
159
- <NavLink href="/settings" icon={<Settings2 className="h-5 w-5 text-primary" />}>
159
+ {/* <NavLink href="/settings" icon={<Settings2 className="h-5 w-5 text-primary" />}>
160
160
  Settings
161
161
  </NavLink>
162
+ */}
162
163
  <NavCommand className="ml-auto" />
163
164
  </div>
164
165
  </nav>
@@ -1,14 +1,16 @@
1
- import { promises as fs } from 'fs'
2
- import * as path from 'path'
3
- import prisma from '@/config/db-config'
4
- import { ensureAutomationWorkspaceReady, getAutomationEnvironmentsDir } from '@/lib/automation/paths'
5
-
6
- interface EnvironmentConfig {
7
- baseUrl: string
8
- apiBaseUrl: string
9
- email: string
10
- password: string
11
- }
1
+ import { promises as fs } from 'fs'
2
+ import * as path from 'path'
3
+ import prisma from '@/config/db-config'
4
+ import { ensureAutomationWorkspaceReady, getAutomationEnvironmentsDir } from '@/lib/automation/paths'
5
+
6
+ interface EnvironmentConfig {
7
+ baseUrl: string
8
+ apiBaseUrl: string
9
+ email: string
10
+ password: string
11
+ }
12
+
13
+ const EMPTY_ENVIRONMENTS_FILE_CONTENT = '{}\n'
12
14
 
13
15
  export function getEnvironmentsFilePath(): string {
14
16
  return path.join(getAutomationEnvironmentsDir(), 'environments.json')
@@ -44,44 +46,38 @@ export async function generateEnvironmentsContent(): Promise<Record<string, Envi
44
46
  }
45
47
  }
46
48
 
47
- export async function createOrUpdateEnvironmentsFile(): Promise<boolean> {
48
- try {
49
- await ensureAutomationWorkspaceReady()
50
- const filePath = getEnvironmentsFilePath()
51
- await ensureConfigDirectoryExists()
52
-
53
- const content = await generateEnvironmentsContent()
54
-
55
- if (Object.keys(content).length === 0) {
56
- await deleteEnvironmentsFile()
57
- return true
58
- }
59
-
60
- await fs.writeFile(filePath, JSON.stringify(content, null, 2))
61
- return true
49
+ export async function createOrUpdateEnvironmentsFile(): Promise<boolean> {
50
+ try {
51
+ await ensureAutomationWorkspaceReady()
52
+ const filePath = getEnvironmentsFilePath()
53
+ await ensureConfigDirectoryExists()
54
+
55
+ const content = await generateEnvironmentsContent()
56
+
57
+ if (Object.keys(content).length === 0) {
58
+ await fs.writeFile(filePath, EMPTY_ENVIRONMENTS_FILE_CONTENT)
59
+ return true
60
+ }
61
+
62
+ await fs.writeFile(filePath, JSON.stringify(content, null, 2))
63
+ return true
62
64
  } catch (error) {
63
65
  console.error('Error creating/updating environments file:', error)
64
66
  return false
65
67
  }
66
68
  }
67
69
 
68
- export async function deleteEnvironmentsFile(): Promise<boolean> {
69
- try {
70
- await ensureAutomationWorkspaceReady()
71
- const filePath = getEnvironmentsFilePath()
72
-
73
- try {
74
- await fs.access(filePath)
75
- } catch {
76
- return true
77
- }
78
-
79
- await fs.unlink(filePath)
80
- return true
81
- } catch (error) {
82
- console.error('Error deleting environments file:', error)
83
- return false
84
- }
70
+ export async function deleteEnvironmentsFile(): Promise<boolean> {
71
+ try {
72
+ await ensureAutomationWorkspaceReady()
73
+ const filePath = getEnvironmentsFilePath()
74
+ await ensureConfigDirectoryExists()
75
+ await fs.writeFile(filePath, EMPTY_ENVIRONMENTS_FILE_CONTENT)
76
+ return true
77
+ } catch (error) {
78
+ console.error('Error deleting environments file:', error)
79
+ return false
80
+ }
85
81
  }
86
82
 
87
83
  export async function readEnvironmentsFile(): Promise<{
@@ -0,0 +1,44 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { promises as fs } from 'fs'
4
+ import { join } from 'path'
5
+ import { tmpdir } from 'os'
6
+ import { parseFeatureFile } from '@/lib/gherkin-parser'
7
+
8
+ async function withTempFeatureFile(content: string): Promise<string> {
9
+ const dir = await fs.mkdtemp(join(tmpdir(), 'gherkin-parser-'))
10
+ const filePath = join(dir, 'sample.feature')
11
+ await fs.writeFile(filePath, content, 'utf8')
12
+ return filePath
13
+ }
14
+
15
+ test('uses Feature line text as feature description', async () => {
16
+ const filePath = await withTempFeatureFile(`
17
+ @smoke
18
+ Feature: Login workflow
19
+
20
+ Scenario: logs in
21
+ Given user opens app
22
+ `)
23
+
24
+ const parsed = await parseFeatureFile(filePath)
25
+
26
+ assert.ok(parsed)
27
+ assert.equal(parsed?.featureName, 'Login workflow')
28
+ assert.equal(parsed?.featureDescription, 'Login workflow')
29
+ })
30
+
31
+ test('keeps Feature line as description even when free text follows', async () => {
32
+ const filePath = await withTempFeatureFile(`
33
+ Feature: Checkout flow
34
+ Legacy block text that should not override the description
35
+
36
+ Scenario: buys item
37
+ Given user adds item to cart
38
+ `)
39
+
40
+ const parsed = await parseFeatureFile(filePath)
41
+
42
+ assert.ok(parsed)
43
+ assert.equal(parsed?.featureDescription, 'Checkout flow')
44
+ })
@@ -1,259 +1,253 @@
1
- import { promises as fs } from 'fs'
2
- import { join, relative } from 'path'
3
-
4
- /**
5
- * Represents a parsed feature file with its scenarios and steps
6
- */
7
- export interface ParsedFeature {
8
- filePath: string
9
- featureName: string
10
- featureDescription?: string
11
- tags: string[]
12
- scenarios: ParsedScenario[]
13
- }
14
-
15
- /**
16
- * Represents a parsed scenario from a feature file
17
- */
18
- export interface ParsedScenario {
19
- name: string
20
- description?: string
21
- tags: string[]
22
- steps: ParsedStep[]
23
- }
24
-
25
- /**
26
- * Represents a parsed step from a feature file
27
- */
28
- export interface ParsedStep {
29
- keyword: string
30
- text: string
31
- order: number
32
- }
33
-
34
- /**
35
- * Parses a Gherkin feature file and extracts scenarios and steps
36
- * @param filePath - Path to the feature file
37
- * @returns Promise<ParsedFeature | null> - Parsed feature data or null if parsing fails
38
- */
39
- export async function parseFeatureFile(filePath: string): Promise<ParsedFeature | null> {
40
- try {
41
- const content = await fs.readFile(filePath, 'utf-8')
42
-
43
- // Simple Gherkin parser implementation
44
- const lines = content.split('\n').map(line => line.trim())
45
- const scenarios: ParsedScenario[] = []
46
-
47
- let featureName = ''
48
- let featureDescription = ''
49
- const featureTags: string[] = []
50
- let currentScenario: ParsedScenario | null = null
51
- let stepOrder = 1
52
-
53
- // Find feature line and extract tags before it
54
- let _featureLineIndex = -1
55
- for (let i = 0; i < lines.length; i++) {
56
- if (lines[i].startsWith('Feature:')) {
57
- _featureLineIndex = i
58
- // Look backwards for tags (skip comments and empty lines)
59
- for (let j = i - 1; j >= 0; j--) {
60
- const prevLine = lines[j]
61
- if (prevLine === '' || prevLine.startsWith('#')) {
62
- continue
63
- }
64
- if (prevLine.startsWith('@')) {
65
- featureTags.unshift(prevLine) // Add to beginning to maintain order
66
- } else {
67
- break // Stop when we hit a non-tag line
68
- }
69
- }
70
- break
71
- }
72
- }
73
-
74
- for (let i = 0; i < lines.length; i++) {
75
- const line = lines[i]
76
-
77
- // Skip comments and empty lines
78
- if (line.startsWith('#') || line === '') {
79
- continue
80
- }
81
-
82
- // Parse Feature line
83
- if (line.startsWith('Feature:')) {
84
- featureName = line.replace('Feature:', '').trim()
85
- // Look for description in next lines
86
- let j = i + 1
87
- while (j < lines.length && lines[j] && !lines[j].startsWith('Scenario:') && !lines[j].startsWith('Feature:')) {
88
- if (lines[j] && !lines[j].startsWith('#')) {
89
- featureDescription += (featureDescription ? ' ' : '') + lines[j]
90
- }
91
- j++
92
- }
93
- continue
94
- }
95
-
96
- // Parse Scenario line
97
- if (line.startsWith('Scenario:')) {
98
- // Save previous scenario if exists
99
- if (currentScenario) {
100
- scenarios.push(currentScenario)
101
- }
102
-
103
- // Extract tags before this scenario
104
- const scenarioTags: string[] = []
105
- for (let j = i - 1; j >= 0; j--) {
106
- const prevLine = lines[j]
107
- if (prevLine === '' || prevLine.startsWith('#')) {
108
- continue
109
- }
110
- if (prevLine.startsWith('@')) {
111
- scenarioTags.unshift(prevLine) // Add to beginning to maintain order
112
- } else {
113
- break // Stop when we hit a non-tag line
114
- }
115
- }
116
-
117
- const scenarioText = line.replace('Scenario:', '').trim()
118
- const [name, description] =
119
- scenarioText.split(']').length > 1
120
- ? [scenarioText.split(']')[1].trim(), scenarioText.split(']')[0].replace('[', '').trim()]
121
- : [scenarioText, '']
122
-
123
- currentScenario = {
124
- name: name,
125
- description: description || undefined,
126
- tags: scenarioTags,
127
- steps: [],
128
- }
129
- stepOrder = 1
130
- continue
131
- }
132
-
133
- // Parse steps (Given, When, Then, And, But)
134
- if (
135
- currentScenario &&
136
- (line.startsWith('Given ') ||
137
- line.startsWith('When ') ||
138
- line.startsWith('Then ') ||
139
- line.startsWith('And ') ||
140
- line.startsWith('But '))
141
- ) {
142
- const keyword = line.split(' ')[0]
143
- const text = line.substring(keyword.length).trim()
144
-
145
- currentScenario.steps.push({
146
- keyword: keyword,
147
- text: text,
148
- order: stepOrder++,
149
- })
150
- }
151
- }
152
-
153
- // Add the last scenario
154
- if (currentScenario) {
155
- scenarios.push(currentScenario)
156
- }
157
-
158
- if (!featureName) {
159
- console.warn(`No feature found in file: ${filePath}`)
160
- return null
161
- }
162
-
163
- return {
164
- filePath,
165
- featureName,
166
- featureDescription: featureDescription || undefined,
167
- tags: featureTags,
168
- scenarios,
169
- }
170
- } catch (error) {
171
- console.error(`Error parsing feature file ${filePath}:`, error)
172
- return null
173
- }
174
- }
175
-
176
- /**
177
- * Scans a directory for feature files and parses them
178
- * @param directoryPath - Path to scan for feature files
179
- * @returns Promise<ParsedFeature[]> - Array of parsed feature files
180
- */
181
- export async function scanFeatureFiles(directoryPath: string): Promise<ParsedFeature[]> {
182
- const parsedFeatures: ParsedFeature[] = []
183
-
184
- try {
185
- const entries = await fs.readdir(directoryPath, { withFileTypes: true })
186
-
187
- for (const entry of entries) {
188
- const fullPath = join(directoryPath, entry.name)
189
-
190
- if (entry.isDirectory()) {
191
- // Recursively scan subdirectories
192
- const subFeatures = await scanFeatureFiles(fullPath)
193
- parsedFeatures.push(...subFeatures)
194
- } else if (entry.isFile() && entry.name.endsWith('.feature')) {
195
- // Parse feature file
196
- const parsedFeature = await parseFeatureFile(fullPath)
197
- if (parsedFeature) {
198
- parsedFeatures.push(parsedFeature)
199
- }
200
- }
201
- }
202
- } catch (error) {
203
- console.error(`Error scanning directory ${directoryPath}:`, error)
204
- }
205
-
206
- return parsedFeatures
207
- }
208
-
209
- /**
210
- * Extracts module path from feature file path
211
- * Works cross-platform (Windows, Mac, Linux)
212
- * @param featureFilePath - Full path to the feature file
213
- * @param featuresBaseDir - Base directory for features
214
- * @returns string - Module path (e.g., "/module1/submodule")
215
- */
216
- export function extractModulePathFromFilePath(featureFilePath: string, featuresBaseDir: string): string {
217
- // Use path.relative for cross-platform path handling
218
- const relativePath = relative(featuresBaseDir, featureFilePath)
219
-
220
- // Normalize to forward slashes for module path format (database uses /)
221
- const normalizedPath = relativePath.replace(/\\/g, '/')
222
- const pathParts = normalizedPath.split('/').filter(part => part && part !== '')
223
-
224
- // Remove the filename and join the remaining parts
225
- const moduleParts = pathParts.slice(0, -1)
226
- return moduleParts.length > 0 ? '/' + moduleParts.join('/') : '/'
227
- }
228
-
229
- /**
230
- * Generates a safe test suite name from feature name
231
- * @param featureName - Name of the feature
232
- * @returns string - Safe test suite name
233
- */
234
- export function generateSafeTestSuiteName(featureName: string): string {
235
- return featureName
236
- .toLowerCase()
237
- .replace(/[^a-z0-9\s]+/g, '')
238
- .replace(/\s+/g, ' ')
239
- .trim()
240
- }
241
-
242
- /**
243
- * Generates a safe test case name from scenario name
244
- * @param scenarioName - Name of the scenario
245
- * @returns string - Safe test case name
246
- */
247
- export function generateSafeTestCaseName(scenarioName: string): string {
248
- // Remove scenario prefix if present and clean up the name
249
- const cleanName = scenarioName
250
- .replace(/^Scenario:\s*/i, '')
251
- .replace(/^\[.*?\]\s*/, '') // Remove [brackets] prefix
252
- .trim()
253
-
254
- return cleanName
255
- .toLowerCase()
256
- .replace(/[^a-z0-9\s]+/g, '')
257
- .replace(/\s+/g, ' ')
258
- .trim()
259
- }
1
+ import { promises as fs } from 'fs'
2
+ import { join, relative } from 'path'
3
+
4
+ /**
5
+ * Represents a parsed feature file with its scenarios and steps
6
+ */
7
+ export interface ParsedFeature {
8
+ filePath: string
9
+ featureName: string
10
+ featureDescription?: string
11
+ tags: string[]
12
+ scenarios: ParsedScenario[]
13
+ }
14
+
15
+ /**
16
+ * Represents a parsed scenario from a feature file
17
+ */
18
+ export interface ParsedScenario {
19
+ name: string
20
+ description?: string
21
+ tags: string[]
22
+ steps: ParsedStep[]
23
+ }
24
+
25
+ /**
26
+ * Represents a parsed step from a feature file
27
+ */
28
+ export interface ParsedStep {
29
+ keyword: string
30
+ text: string
31
+ order: number
32
+ }
33
+
34
+ /**
35
+ * Parses a Gherkin feature file and extracts scenarios and steps
36
+ * @param filePath - Path to the feature file
37
+ * @returns Promise<ParsedFeature | null> - Parsed feature data or null if parsing fails
38
+ */
39
+ export async function parseFeatureFile(filePath: string): Promise<ParsedFeature | null> {
40
+ try {
41
+ const content = await fs.readFile(filePath, 'utf-8')
42
+
43
+ // Simple Gherkin parser implementation
44
+ const lines = content.split('\n').map(line => line.trim())
45
+ const scenarios: ParsedScenario[] = []
46
+
47
+ let featureName = ''
48
+ let featureDescription = ''
49
+ const featureTags: string[] = []
50
+ let currentScenario: ParsedScenario | null = null
51
+ let stepOrder = 1
52
+
53
+ // Find feature line and extract tags before it
54
+ let _featureLineIndex = -1
55
+ for (let i = 0; i < lines.length; i++) {
56
+ if (lines[i].startsWith('Feature:')) {
57
+ _featureLineIndex = i
58
+ // Look backwards for tags (skip comments and empty lines)
59
+ for (let j = i - 1; j >= 0; j--) {
60
+ const prevLine = lines[j]
61
+ if (prevLine === '' || prevLine.startsWith('#')) {
62
+ continue
63
+ }
64
+ if (prevLine.startsWith('@')) {
65
+ featureTags.unshift(prevLine) // Add to beginning to maintain order
66
+ } else {
67
+ break // Stop when we hit a non-tag line
68
+ }
69
+ }
70
+ break
71
+ }
72
+ }
73
+
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const line = lines[i]
76
+
77
+ // Skip comments and empty lines
78
+ if (line.startsWith('#') || line === '') {
79
+ continue
80
+ }
81
+
82
+ // Parse Feature line
83
+ if (line.startsWith('Feature:')) {
84
+ const featureLineText = line.replace('Feature:', '').trim()
85
+ featureName = featureLineText
86
+ featureDescription = featureLineText
87
+ continue
88
+ }
89
+
90
+ // Parse Scenario line
91
+ if (line.startsWith('Scenario:')) {
92
+ // Save previous scenario if exists
93
+ if (currentScenario) {
94
+ scenarios.push(currentScenario)
95
+ }
96
+
97
+ // Extract tags before this scenario
98
+ const scenarioTags: string[] = []
99
+ for (let j = i - 1; j >= 0; j--) {
100
+ const prevLine = lines[j]
101
+ if (prevLine === '' || prevLine.startsWith('#')) {
102
+ continue
103
+ }
104
+ if (prevLine.startsWith('@')) {
105
+ scenarioTags.unshift(prevLine) // Add to beginning to maintain order
106
+ } else {
107
+ break // Stop when we hit a non-tag line
108
+ }
109
+ }
110
+
111
+ const scenarioText = line.replace('Scenario:', '').trim()
112
+ const [name, description] =
113
+ scenarioText.split(']').length > 1
114
+ ? [scenarioText.split(']')[1].trim(), scenarioText.split(']')[0].replace('[', '').trim()]
115
+ : [scenarioText, '']
116
+
117
+ currentScenario = {
118
+ name: name,
119
+ description: description || undefined,
120
+ tags: scenarioTags,
121
+ steps: [],
122
+ }
123
+ stepOrder = 1
124
+ continue
125
+ }
126
+
127
+ // Parse steps (Given, When, Then, And, But)
128
+ if (
129
+ currentScenario &&
130
+ (line.startsWith('Given ') ||
131
+ line.startsWith('When ') ||
132
+ line.startsWith('Then ') ||
133
+ line.startsWith('And ') ||
134
+ line.startsWith('But '))
135
+ ) {
136
+ const keyword = line.split(' ')[0]
137
+ const text = line.substring(keyword.length).trim()
138
+
139
+ currentScenario.steps.push({
140
+ keyword: keyword,
141
+ text: text,
142
+ order: stepOrder++,
143
+ })
144
+ }
145
+ }
146
+
147
+ // Add the last scenario
148
+ if (currentScenario) {
149
+ scenarios.push(currentScenario)
150
+ }
151
+
152
+ if (!featureName) {
153
+ console.warn(`No feature found in file: ${filePath}`)
154
+ return null
155
+ }
156
+
157
+ return {
158
+ filePath,
159
+ featureName,
160
+ featureDescription: featureDescription || undefined,
161
+ tags: featureTags,
162
+ scenarios,
163
+ }
164
+ } catch (error) {
165
+ console.error(`Error parsing feature file ${filePath}:`, error)
166
+ return null
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Scans a directory for feature files and parses them
172
+ * @param directoryPath - Path to scan for feature files
173
+ * @returns Promise<ParsedFeature[]> - Array of parsed feature files
174
+ */
175
+ export async function scanFeatureFiles(directoryPath: string): Promise<ParsedFeature[]> {
176
+ const parsedFeatures: ParsedFeature[] = []
177
+
178
+ try {
179
+ const entries = await fs.readdir(directoryPath, { withFileTypes: true })
180
+
181
+ for (const entry of entries) {
182
+ const fullPath = join(directoryPath, entry.name)
183
+
184
+ if (entry.isDirectory()) {
185
+ // Recursively scan subdirectories
186
+ const subFeatures = await scanFeatureFiles(fullPath)
187
+ parsedFeatures.push(...subFeatures)
188
+ } else if (entry.isFile() && entry.name.endsWith('.feature')) {
189
+ // Parse feature file
190
+ const parsedFeature = await parseFeatureFile(fullPath)
191
+ if (parsedFeature) {
192
+ parsedFeatures.push(parsedFeature)
193
+ }
194
+ }
195
+ }
196
+ } catch (error) {
197
+ console.error(`Error scanning directory ${directoryPath}:`, error)
198
+ }
199
+
200
+ return parsedFeatures
201
+ }
202
+
203
+ /**
204
+ * Extracts module path from feature file path
205
+ * Works cross-platform (Windows, Mac, Linux)
206
+ * @param featureFilePath - Full path to the feature file
207
+ * @param featuresBaseDir - Base directory for features
208
+ * @returns string - Module path (e.g., "/module1/submodule")
209
+ */
210
+ export function extractModulePathFromFilePath(featureFilePath: string, featuresBaseDir: string): string {
211
+ // Use path.relative for cross-platform path handling
212
+ const relativePath = relative(featuresBaseDir, featureFilePath)
213
+
214
+ // Normalize to forward slashes for module path format (database uses /)
215
+ const normalizedPath = relativePath.replace(/\\/g, '/')
216
+ const pathParts = normalizedPath.split('/').filter(part => part && part !== '')
217
+
218
+ // Remove the filename and join the remaining parts
219
+ const moduleParts = pathParts.slice(0, -1)
220
+ return moduleParts.length > 0 ? '/' + moduleParts.join('/') : '/'
221
+ }
222
+
223
+ /**
224
+ * Generates a safe test suite name from feature name
225
+ * @param featureName - Name of the feature
226
+ * @returns string - Safe test suite name
227
+ */
228
+ export function generateSafeTestSuiteName(featureName: string): string {
229
+ return featureName
230
+ .toLowerCase()
231
+ .replace(/[^a-z0-9\s]+/g, '')
232
+ .replace(/\s+/g, ' ')
233
+ .trim()
234
+ }
235
+
236
+ /**
237
+ * Generates a safe test case name from scenario name
238
+ * @param scenarioName - Name of the scenario
239
+ * @returns string - Safe test case name
240
+ */
241
+ export function generateSafeTestCaseName(scenarioName: string): string {
242
+ // Remove scenario prefix if present and clean up the name
243
+ const cleanName = scenarioName
244
+ .replace(/^Scenario:\s*/i, '')
245
+ .replace(/^\[.*?\]\s*/, '') // Remove [brackets] prefix
246
+ .trim()
247
+
248
+ return cleanName
249
+ .toLowerCase()
250
+ .replace(/[^a-z0-9\s]+/g, '')
251
+ .replace(/\s+/g, ' ')
252
+ .trim()
253
+ }
@@ -123,6 +123,30 @@ test('matches test suites by generated filesystem key instead of raw DB name', (
123
123
  assert.equal(count, 0)
124
124
  })
125
125
 
126
+ test('matches test suites when DB description is null and feature uses suite name', () => {
127
+ const count = countTestSuiteMismatches(
128
+ [
129
+ {
130
+ name: 'user-login-suite',
131
+ description: 'User Login Suite',
132
+ modulePath: '/auth',
133
+ tags: [],
134
+ },
135
+ ],
136
+ [
137
+ {
138
+ name: 'User Login Suite',
139
+ description: null,
140
+ moduleId: 'module-auth',
141
+ tags: [],
142
+ },
143
+ ],
144
+ new Map([['module-auth', '/auth']]),
145
+ )
146
+
147
+ assert.equal(count, 0)
148
+ })
149
+
126
150
  test('matches projected test cases against generated feature-file output', () => {
127
151
  const count = countTestCaseMismatches(
128
152
  [
@@ -825,7 +825,7 @@ async function buildFilesystemSnapshot(baseDir: string): Promise<FilesystemSnaps
825
825
 
826
826
  const testSuites: TestSuiteFromFs[] = parsedFeatures.map(feature => ({
827
827
  name: extractTestSuiteNameFromFilename(feature.filePath),
828
- description: feature.featureDescription ?? null,
828
+ description: feature.featureDescription ?? feature.featureName ?? null,
829
829
  modulePath: extractModulePathFromFilePath(feature.filePath, featuresDir),
830
830
  tags: extractFeatureLevelTags(feature),
831
831
  }))
@@ -1099,7 +1099,8 @@ export function countTestSuiteMismatches(
1099
1099
  const fsTagExpressions = suite.tags.map(normalizeTagExpression)
1100
1100
  const hasMatch = (dbByKey.get(suiteKey) ?? []).some(existing => {
1101
1101
  const dbTagExpressions = existing.tags.map(tag => normalizeTagExpression(tag.tagExpression))
1102
- return (existing.description ?? null) === (suite.description ?? null) && sameStringSet(dbTagExpressions, fsTagExpressions)
1102
+ const expectedDescription = existing.description ?? existing.name
1103
+ return expectedDescription === (suite.description ?? null) && sameStringSet(dbTagExpressions, fsTagExpressions)
1103
1104
  })
1104
1105
 
1105
1106
  if (!hasMatch) {