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

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.2",
4
4
  "description": "Scaffold a new AppraiseJS app in your directory",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hasnat Jamil",
@@ -1,5 +1,5 @@
1
1
  {
2
- "preparedAt": "2026-03-23T05:51:28.888Z",
3
- "inputHash": "538288241ebcec3d1c8efa09eaaba4e5aec5611c00011ab1ec6c2c6c31341fb5",
2
+ "preparedAt": "2026-03-23T19:35:53.852Z",
3
+ "inputHash": "165288718820de7247b9067d6c4f1a78057520fb791c4b28fa781f2c30c95ea3",
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
@@ -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) {