Skip to content

Commit

Permalink
fix: snippets now fully supported
Browse files Browse the repository at this point in the history
  • Loading branch information
dnotes committed Nov 27, 2024
1 parent 2611f1d commit a28faaa
Show file tree
Hide file tree
Showing 11 changed files with 2,045 additions and 347 deletions.
6 changes: 6 additions & 0 deletions .changeset/rare-garlics-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@quickpickle/playwright": patch
"quickpickle": patch
---

Snippets are now well supported, producing async/await javascript with appropriate variables.
15 changes: 4 additions & 11 deletions packages/main/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ by parsing them with the official [Gherkin Parser] and running them as Vitest te
- [x] [profiles] *(use Vitest [workspaces])*
- [x] [rerun] *(press "f" in [watch mode terminal])*
- [x] [retry] *(use Vitest [retry option])*
- [ ] [snippets] *poor support* **[PLANNED]**
- [x] [snippets] *(only async/await javascript is supported)*
- [x] [transpiling] **100% support through Vite (the reason for this package)**

[Gherkin 6]: https://cucumber.io/docs/gherkin/reference/
Expand Down Expand Up @@ -446,21 +446,14 @@ come to notice:

### Unsupported CucumberJS Features

#### Planned for feature completeness:

- [snippets] full support, including proper variables

Snippets are currently not well supported, as they don't yet include the proper variables or language formatting.

#### Other unsupported features:

- return "skipped" in step definitions or hooks to skip a step definition or hook
- return "skipped" in step definitions or hooks to skip a step definition or hook *(use `world.context.skip()`)*
- [setDefaultTimeout] *(use [testTimeout] in Vitest config)*
- [setDefinitionFunctionWrapper] *(use Before and After hooks)*
- [setParallelCanAssign] *(use @concurrent and @sequential [special tags])*
- attachments with [world.attach], [world.log], and [world.link] *not supported, use Vitest [reporters]*
- [dry run] *not supported, but you can use `vitest list` which is similar*
- [filtering] full support, including CucumberJS tag matching with "and", "not", etc.
- [custom snippet formats] *(only async/await javascript is supported)*
- [filtering] CucumberJS tag matching with "and", "not", etc.
In Vitest it is only possible to match the name of the test. With QuickPickle tests
the name does include the tags, so running `vitest -t "@some-tag"` does work, but
you can't specify that it should run all tests *without* a certain tag.
Expand Down
128 changes: 64 additions & 64 deletions packages/main/gherkin-example/example.feature.js

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions packages/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,25 @@
"test": "vitest --run"
},
"dependencies": {
"@cucumber/cucumber": "^11.0.1",
"@cucumber/cucumber-expressions": "^16.1.2",
"@cucumber/cucumber": "^11.1.0",
"@cucumber/cucumber-expressions": "^17.1.0",
"@cucumber/gherkin": "^29.0.0",
"@cucumber/messages": "^22.0.0",
"@cucumber/tag-expressions": "^6.1.0",
"@cucumber/tag-expressions": "^6.1.1",
"lodash-es": "^4.17.21"
},
"devDependencies": {
"@rollup/plugin-replace": "^6.0.1",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.0",
"@types/lodash": "^4.14.194",
"@rollup/plugin-typescript": "^12.1.1",
"@types/lodash": "^4.17.13",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18.16.3",
"@vitest/browser": "^2.1.2",
"playwright": "^1.48.0",
"rollup": "^3.20.7",
"typescript": "^5.6.2",
"vitest": "^2.1.2"
"@types/node": "^18.19.66",
"@vitest/browser": "^2.1.6",
"playwright": "^1.49.0",
"rollup": "^3.29.5",
"typescript": "^5.7.2",
"vitest": "^2.1.6"
},
"peerDependencies": {
"vitest": "^1.0.0 || >=2.0.0"
Expand Down
37 changes: 21 additions & 16 deletions packages/main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,26 +54,31 @@ export function formatStack(text:string, line:string) {
return stack.join('\n')
}

export const gherkinStep = async (step: string, state: any, line: number, stepIdx:number, explodeIdx?:number, data?:any): Promise<any> => {
const stepDefinitionMatch: StepDefinitionMatch = findStepDefinitionMatch(step);

// Set the state info
state.info.step = step
state.info.line = line
state.info.stepIdx = stepIdx
state.info.explodedIdx = explodeIdx

// Sort out the DataTable or DocString
if (Array.isArray(data)) {
data = new DataTable(data)
}
else if (data?.hasOwnProperty('content')) {
data = new DocString(data.content, data.mediaType)
}
export const gherkinStep = async (stepType:"Context"|"Action"|"Outcome", step: string, state: any, line: number, stepIdx:number, explodeIdx?:number, data?:any): Promise<any> => {

try {
// Set the state info
state.info.stepType = stepType
state.info.step = step
state.info.line = line
state.info.stepIdx = stepIdx
state.info.explodedIdx = explodeIdx

// Sort out the DataTable or DocString
let dataType = ''
if (Array.isArray(data)) {
data = new DataTable(data)
dataType = 'dataTable'
}
else if (data?.hasOwnProperty('content')) {
data = new DocString(data.content, data.mediaType)
dataType = 'docString'
}

await applyHooks('beforeStep', state);

try {
const stepDefinitionMatch: StepDefinitionMatch = findStepDefinitionMatch(step, { stepType, dataType });
await stepDefinitionMatch.stepDefinition.f(state, ...stepDefinitionMatch.parameters, data);
}
catch(e:any) {
Expand Down
18 changes: 15 additions & 3 deletions packages/main/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,29 @@ function renderSteps(steps:Step[], config:QuickPickleConfig, sp = ' ', exploded
let data = JSON.stringify(step.dataTable.rows.map(r => {
return r.cells.map(c => c.value)
}))
return `${sp}await gherkinStep(${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}, ${explodedText || 'undefined'}, ${data});`
return `${sp}await gherkinStep('${getStepType(steps, idx)}', ${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}, ${explodedText || 'undefined'}, ${data});`
}
else if (step.docString) {
let data = JSON.stringify(pick(step.docString, ['content','mediaType']))
return `${sp}await gherkinStep(${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}, ${explodedText || 'undefined'}, ${data});`
return `${sp}await gherkinStep('${getStepType(steps, idx)}', ${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}, ${explodedText || 'undefined'}, ${data});`
}

return `${sp}await gherkinStep(${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}${explodedText ? `, ${explodedText}` : ''});`
return `${sp}await gherkinStep('${getStepType(steps, idx)}', ${tl(step.text)}, state, ${step.location.line}, ${minus}${idx+1}${explodedText ? `, ${explodedText}` : ''});`
}).join('\n')
}

function getStepType(steps:Step[], idx:number) {
switch (steps[idx].keywordType) {
case 'Context':
case 'Action':
case 'Outcome':
return steps[idx].keywordType
default:
if (idx) return getStepType(steps, idx-1)
return 'Context'
}
}

/**
* Escapes quotation marks in a string for the purposes of this rendering function.
* @param t string
Expand Down
60 changes: 52 additions & 8 deletions packages/main/src/steps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ExpressionFactory, ParameterTypeRegistry, Expression, ParameterType } from '@cucumber/cucumber-expressions';
import { ExpressionFactory, ParameterTypeRegistry, Expression, ParameterType, CucumberExpressionGenerator, GeneratedExpression } from '@cucumber/cucumber-expressions';
import { IParameterTypeDefinition } from '@cucumber/cucumber/lib/support_code_library_builder/types';

interface StepDefinition {
Expand Down Expand Up @@ -31,6 +31,8 @@ const typeName: Record<string, string> = {
const parameterTypeRegistry = new ParameterTypeRegistry();
const expressionFactory = new ExpressionFactory(parameterTypeRegistry);

const cucumberExpressionGenerator = new CucumberExpressionGenerator(() => parameterTypeRegistry.parameterTypes)

const buildParameterType = (type:IParameterTypeDefinition<any>): ParameterType<unknown> => {
return new ParameterType(
type.name,
Expand Down Expand Up @@ -65,17 +67,18 @@ const findStepDefinitionMatches = (step:string): StepDefinitionMatch[] => {
}, []);
};

export const findStepDefinitionMatch = (step:string): StepDefinitionMatch => {
type SnippetData = {
stepType:'Context'|'Action'|'Outcome'
dataType:string
}
export const findStepDefinitionMatch = (step:string, snippetData:SnippetData): StepDefinitionMatch => {
const stepDefinitionMatches = findStepDefinitionMatches(step);

// If it's not found
if (!stepDefinitionMatches || stepDefinitionMatches.length === 0) {
let snippet = getSnippet(step, snippetData);
throw new Error(`Undefined. Implement with the following snippet:
Given('${step}', (world, ...params) => {
// Write code here that turns the phrase above into concrete actions
throw new Error('Not yet implemented!');
return state;
});
${snippet}
`);
}

Expand All @@ -85,3 +88,44 @@ export const findStepDefinitionMatch = (step:string): StepDefinitionMatch => {

return stepDefinitionMatches[0];
};

function getSnippet(step:string, { stepType, dataType }:SnippetData):string {

const generatedExpressions = cucumberExpressionGenerator.generateExpressions(step);
const stepParameterNames = dataType ? [dataType] : []

let functionName:string = 'Given'
if (stepType === 'Action') functionName = 'When'
if (stepType === 'Outcome') functionName = 'Then'

let implementation = "throw new Error('Not yet implemented')"

const definitionChoices = generatedExpressions.map(
(generatedExpression, index) => {
const prefix = index === 0 ? '' : '// '
const allParameterNames = ['world'].concat(
generatedExpression.parameterNames,
stepParameterNames
)
return `${prefix + functionName}('${escapeSpecialCharacters(
generatedExpression
)}', async function (${allParameterNames.join(', ')}) {\n`
}
)

return (
`${definitionChoices.join('')} // Write code here that turns the phrase above into concrete actions\n` +
` ${implementation}\n` +
'});'
)

}

function escapeSpecialCharacters(generatedExpression: GeneratedExpression) {
let source = generatedExpression.source
// double up any backslashes because we're in a javascript string
source = source.replace(/\\/g, '\\\\')
// escape any single quotes because that's our quote delimiter
source = source.replace(/'/g, "\\'")
return source
}
47 changes: 46 additions & 1 deletion packages/main/tests/test.feature
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,49 @@ Feature: Basic Test
Example: a custom parameter only matches its exact regex
Given a number 1
When I push all the numbers right
Then the error 1 should contain "Undefined. Implement with the following snippet:"
Then the error 1 should contain "Undefined. Implement with the following snippet:"
@snippets @soft
Rule: Snippets must be helpful
Example: a custom parameter works
Given the phrase goes up
Then error 1 should contain the following text:
"""js
Given('the phrase goes {updown}', async function (world, updown) {
// Write code here that turns the phrase above into concrete actions
throw new Error('Not yet implemented')
});
"""
And clear error 1
Example: multiple parameters work
When a phrase with "first" and "second"
Then error 1 should contain the following text:
```js
When('a phrase with {string} and {string}', async function (world, string, string2) {
// Write code here that turns the phrase above into concrete actions
throw new Error('Not yet implemented')
});
```
And clear error 1
Example: DataTables work
And a step with a DataTable:
| key | value |
| 1 | data table |
And error 1 should contain "Given('a step with a DataTable:', async function (world, dataTable)"
And clear error 1
Example: DocStrings should work
Then a step with a DocString:
"""
this is a DocString.
"""
Then error 1 should contain "Then('a step with a DocString:', async function (world, docString)"
And clear error 1
Example: no variables should work
Given this step is undefined
Then error 1 should contain "Given('this step is undefined', async function (world) {"
And clear error 1
4 changes: 4 additions & 0 deletions packages/main/tests/tests.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ Then('(the )error {int} should contain {string}', async (world, idx, expected) =
let error = world.info.errors[idx-1]
await expect(error.message).toContain(expected)
})
Then('(the )error {int} should contain the following text:', async (world, idx, expected) => {
let error = world.info.errors[idx-1]
await expect(error.message).toContain(expected.toString())
})
Then('the stack for error {int} should contain {string}', async (world, idx, expected) => {
let stack = world.info.errors[idx-1].stack.split('\n')[0]
await expect(stack).toContain(expected)
Expand Down
16 changes: 8 additions & 8 deletions packages/playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,26 +66,26 @@
},
"author": "David Hunt",
"dependencies": {
"@playwright/test": "^1.48.0",
"@playwright/test": "^1.49.0",
"lodash-es": "^4.17.21",
"pixelmatch": "^6.0.0",
"playwright": "^1.48.0",
"playwright": "^1.49.0",
"pngjs": "^7.0.0",
"quickpickle": "workspace:^",
"vite": "^5.0.11"
"vite": "^5.4.11"
},
"devDependencies": {
"@rollup/plugin-replace": "^6.0.1",
"@rollup/plugin-typescript": "^12.1.0",
"@rollup/plugin-typescript": "^12.1.1",
"@types/jest-image-snapshot": "^6.4.0",
"@types/lodash-es": "^4.17.12",
"@types/pixelmatch": "^5.2.6",
"@types/pngjs": "^6.0.5",
"fast-glob": "^3.3.2",
"js-yaml": "^4.1.0",
"playwright-core": "^1.48.0",
"rollup": "^3.20.7",
"typescript": "^5.6.2",
"vitest": "^2.1.2"
"playwright-core": "^1.49.0",
"rollup": "^3.29.5",
"typescript": "^5.7.2",
"vitest": "^2.1.6"
}
}
Loading

0 comments on commit a28faaa

Please sign in to comment.