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 +10 -5
- package/templates/default/.appraise-template-meta.json +2 -2
- package/templates/default/package.json +1 -1
- package/templates/default/prisma/dev.db +0 -0
- package/templates/default/scripts/sync-environments.ts +7 -3
- package/templates/default/src/app/layout.tsx +2 -1
- package/templates/default/src/lib/environment-file-utils.ts +39 -43
- package/templates/default/src/lib/gherkin-parser.test.ts +44 -0
- package/templates/default/src/lib/gherkin-parser.ts +253 -259
- package/templates/default/src/lib/sync/sync-pending-counts.test.ts +24 -0
- package/templates/default/src/lib/sync/sync-pending-counts.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-appraisejs",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
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
|
|
48
|
-
"publish:alpha": "npm
|
|
49
|
-
"publish:beta": "npm
|
|
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-
|
|
3
|
-
"inputHash": "
|
|
2
|
+
"preparedAt": "2026-03-23T20:15:15.041Z",
|
|
3
|
+
"inputHash": "596a28d2c5dc408d39926068711127354f3752a92c086cd0df27e8cf85eb5990",
|
|
4
4
|
"databasePath": "prisma/dev.db"
|
|
5
5
|
}
|
|
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 =
|
|
50
|
+
const filePath = path.join(getAutomationEnvironmentsDir(), 'environments.json')
|
|
48
51
|
|
|
49
52
|
try {
|
|
50
53
|
await fs.access(filePath)
|
|
51
54
|
} catch {
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.replace(
|
|
251
|
-
.replace(
|
|
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
|
-
|
|
1102
|
+
const expectedDescription = existing.description ?? existing.name
|
|
1103
|
+
return expectedDescription === (suite.description ?? null) && sameStringSet(dbTagExpressions, fsTagExpressions)
|
|
1103
1104
|
})
|
|
1104
1105
|
|
|
1105
1106
|
if (!hasMatch) {
|