├── .eslintignore ├── .eslintrc.yml ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .gitpod.yml ├── .prettierignore ├── .prettierrc ├── .vscode ├── settings.json └── tasks.json ├── README.md ├── api-extractor.json ├── api ├── prescript.api.md └── prescript.d.ts ├── bin └── prescript ├── cli.js ├── docs ├── .vuepress │ ├── config.js │ ├── override.styl │ └── style.styl ├── README.md └── guide │ ├── README.md │ ├── allure1.png │ ├── allure2.png │ ├── api.md │ ├── cli.md │ ├── config.md │ ├── fixed.png │ ├── non-interactive.png │ ├── ouch.png │ ├── ouch2.png │ ├── ouch3.png │ ├── page-object-intellisense.png │ ├── reload.png │ ├── shell.png │ ├── tagged.png │ ├── tips.md │ ├── tutorial.md │ ├── typing-complete.png │ ├── typing-hint.png │ ├── typing-nocomplete.png │ ├── typing-unknown.png │ ├── typings.md │ ├── what-happened.png │ ├── what-happened2.png │ └── writing-tests.md ├── examples ├── action-wrapper │ ├── lib │ │ └── stats.js │ ├── prescript.config.js │ └── tests │ │ └── Wrapper-test.js ├── attachment │ └── tests │ │ └── Attachment-test.js ├── calculator │ ├── lib │ │ └── Calculator.js │ ├── test-lib │ │ └── CalculatorTester.js │ └── tests │ │ ├── Basic addition (flat).js │ │ ├── Basic addition (flat, old api).js │ │ ├── Basic addition (nested).js │ │ ├── Basic addition (nested, old api).js │ │ ├── Basic addition (nested, tagged template literal syntax).js │ │ ├── Basic addition (page object).js │ │ ├── Basic addition (page object, old api).js │ │ └── Basic addition (singleton api).js ├── defer │ └── tests │ │ └── Defer-test.js ├── independent │ └── tests │ │ └── Independent-test.fail ├── multiple │ └── tests │ │ └── tests.js ├── pending │ └── tests │ │ ├── Implicit-pending-composite-step.js │ │ ├── Implicit-pending-test.js │ │ └── Pending-test.js ├── prescription-state │ └── tests │ │ └── PrescriptionState-test.js ├── regression │ ├── fixtures │ │ └── Issue27-AnsiColorTestFixture.js │ └── tests │ │ ├── Issue27.js │ │ └── Issue34.js ├── reporters │ ├── fixtures │ │ ├── ExampleTestFixture.js │ │ └── prescript.config.js │ └── tests │ │ └── Reporter-test.js └── testAll.js ├── jest.config.js ├── package.json ├── src ├── PendingError.ts ├── StepName.ts ├── cli.ts ├── configuration.ts ├── createReporter.ts ├── createTestIterator.test.ts ├── createTestIterator.ts ├── createUI.ts ├── currentActionContext.ts ├── globalState.ts ├── isStepExist.test.ts ├── isStepExist.ts ├── loadTestModule.test.ts ├── loadTestModule.ts ├── prettyFormatStep.ts ├── singleton.ts ├── singletonAllureInstance.ts ├── singletonApi.ts ├── types.ts └── walkSteps.ts ├── tsconfig.json ├── types-test └── types-test.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | **/lib/* 2 | **/node_modules/* 3 | **/allure-*/* 4 | /api/ -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parser: '@typescript-eslint/parser' 2 | parserOptions: 3 | ecmaVersion: 2017 4 | sourceType: module 5 | plugins: 6 | - prettier 7 | extends: 8 | - eslint:recommended 9 | - plugin:prettier/recommended 10 | env: 11 | node: true 12 | rules: 13 | no-console: off 14 | # Let TypeScript compiler check for these! 15 | no-undef: off 16 | no-unused-vars: off 17 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | - docs-revamp 6 | 7 | jobs: 8 | docs: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | persist-credentials: false 14 | 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version-file: package.json 18 | cache: yarn 19 | 20 | - run: yarn install --frozen-lockfile 21 | 22 | - run: yarn docs:build 23 | 24 | - uses: peaceiris/actions-gh-pages@8457ade3d7045b4842e5a10f733d4153b7d05238 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | publish_dir: ./docs/.vuepress/dist 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | 4 | jobs: 5 | check: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version-file: package.json 13 | cache: yarn 14 | 15 | - run: yarn install --frozen-lockfile 16 | 17 | - run: yarn lint 18 | 19 | - run: yarn test 20 | 21 | - run: yarn build 22 | 23 | build: 24 | needs: check 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: actions/setup-node@v4 30 | with: 31 | node-version: 20 32 | cache: yarn 33 | 34 | - run: yarn install --frozen-lockfile 35 | 36 | - name: Update API reports 37 | run: mkdir -p api && yarn api-extractor run --local 38 | 39 | - uses: stefanzweifel/git-auto-commit-action@v4 40 | with: 41 | commit_message: Update API report as of ${{ github.sha }} 42 | file_pattern: api 43 | commit_user_name: rebasecop 44 | commit_user_email: rebasecop@users.noreply.github.com 45 | commit_author: rebasecop 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # created by git-ignore 2 | # Logs 3 | logs 4 | *.log 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # Compiled binary addons (http://nodejs.org/api/addons.html) 21 | build/Release 22 | 23 | # Dependency directory 24 | # Deployed apps should consider commenting this line out: 25 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 26 | node_modules 27 | 28 | # created by git-ignore 29 | .DS_Store 30 | .AppleDouble 31 | .LSOverride 32 | 33 | # Icon must ends with two \r. 34 | Icon 35 | 36 | 37 | # Thumbnails 38 | ._* 39 | 40 | # Files that might appear on external disk 41 | .Spotlight-V100 42 | .Trashes 43 | 44 | /allure-results/ 45 | /allure-report/ 46 | 47 | /lib/ 48 | dist 49 | tmp 50 | api/tsdoc-metadata.json 51 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - init: yarn install 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /api/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "proseWrap": "always" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "typescript", 8 | "tsconfig": "tsconfig.json", 9 | "option": "watch", 10 | "problemMatcher": [ 11 | "$tsc-watch" 12 | ] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prescript 2 | 3 | **prescript** is a Node.js-based test runner that helps make it fun to write 4 | end-to-end/acceptance tests. 5 | 6 | Writing functional and end-to-end tests \(e.g. using Puppeteer or Selenium\) 7 | with unit-testing frameworks such as Mocha can sometimes be painful, because 8 | when one step failed, you have to re-run the test from the beginning to verify 9 | that you fixed it. End-to-end tests is usually very slow compared to unit tests. 10 | 11 | **prescript** solves this problem by allowing you to express your tests as 12 | multiple, discrete steps. **prescript** then comes with an interactive 13 | **development mode,** in which you can **hot-reload the test script** and **jump 14 | between steps.** 15 | 16 | This means as you run your tests as you write it. And if you make a mistake you 17 | can fix your test, hot reload, and continue testing, without having to re-run 18 | the whole test suite. 19 | 20 | ## Documentation 21 | 22 | [Documentation is available on our website.](https://taskworld.github.io/prescript/) 23 | 24 | ## Development 25 | 26 | Running Prescript example scenarios: 27 | 28 | ```sh 29 | yarn test-examples 30 | ``` 31 | 32 | Running individual scenario: 33 | 34 | ```sh 35 | ./bin/prescript "./examples/calculator/tests/Basic addition (page object).js" 36 | ``` 37 | 38 | Running unit tests: 39 | 40 | ```sh 41 | yarn test 42 | ``` -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 3 | "mainEntryPointFilePath": "/lib/singletonApi.d.ts", 4 | "apiReport": { 5 | "enabled": true, 6 | "reportFolder": "/api", 7 | "reportTempFolder": "/tmp/api" 8 | }, 9 | "docModel": { 10 | "enabled": true, 11 | "apiJsonFilePath": "/tmp/api/.api.json" 12 | }, 13 | "dtsRollup": { 14 | "enabled": true, 15 | "untrimmedFilePath": "/api/.d.ts" 16 | } 17 | } -------------------------------------------------------------------------------- /api/prescript.api.md: -------------------------------------------------------------------------------- 1 | ## API Report File for "prescript" 2 | 3 | > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). 4 | 5 | ```ts 6 | 7 | /// 8 | 9 | // @public 10 | export function action(name: string, f: ActionFunction): void; 11 | 12 | // @public 13 | export function action(nameParts: TemplateStringsArray, ...substitutions: any[]): (f?: ActionFunction) => void; 14 | 15 | // @public @deprecated 16 | export function action(f: ActionFunction): void; 17 | 18 | // @public (undocumented) 19 | export type ActionFunction = (state: Prescript.GlobalState, context: ITestExecutionContext) => void | Thenable; 20 | 21 | // @alpha (undocumented) 22 | export type ActionWrapper = (step: IStep, execute: () => Promise, state: Prescript.GlobalState, context: ITestExecutionContext) => Promise; 23 | 24 | // @public @deprecated 25 | export function cleanup(name: StepDefName, f: () => X): X; 26 | 27 | // @public (undocumented) 28 | const _default: { 29 | test: typeof test_2; 30 | to: typeof to; 31 | action: typeof action; 32 | defer: typeof defer; 33 | pending: typeof pending_2; 34 | step: typeof step; 35 | cleanup: typeof cleanup; 36 | onFinish: typeof onFinish; 37 | getCurrentState: typeof getCurrentState; 38 | getCurrentContext: typeof getCurrentContext; 39 | getCurrentPrescriptionState: typeof getCurrentPrescriptionState; 40 | isPendingError: typeof isPendingError; 41 | }; 42 | export default _default; 43 | 44 | // @public 45 | export function defer(name: string, f: ActionFunction): void; 46 | 47 | // @public 48 | export function defer(nameParts: TemplateStringsArray, ...substitutions: any[]): (f?: ActionFunction) => void; 49 | 50 | // @public 51 | export function getCurrentContext(): ITestExecutionContext; 52 | 53 | // @public 54 | export function getCurrentPrescriptionState(): Prescript.PrescriptionState; 55 | 56 | // @public 57 | export function getCurrentState(): Prescript.GlobalState; 58 | 59 | // @public 60 | export interface IConfig { 61 | // @alpha 62 | createTestReporter?(testModulePath: string, testName: string): ITestReporter; 63 | // @alpha 64 | wrapAction?: ActionWrapper; 65 | } 66 | 67 | // @public 68 | export function independent(f: () => X): X; 69 | 70 | // @public 71 | export function isPendingError(e: any): boolean; 72 | 73 | // @alpha (undocumented) 74 | export interface IStep { 75 | // (undocumented) 76 | action?: ActionFunction; 77 | // (undocumented) 78 | actionDefinition?: string; 79 | // (undocumented) 80 | children?: IStep[]; 81 | // (undocumented) 82 | cleanup?: boolean; 83 | // (undocumented) 84 | creator?: string; 85 | // (undocumented) 86 | defer?: boolean; 87 | // (undocumented) 88 | definition?: string; 89 | // (undocumented) 90 | independent?: boolean; 91 | // (undocumented) 92 | name: StepName; 93 | // (undocumented) 94 | number?: string; 95 | // (undocumented) 96 | pending?: boolean; 97 | } 98 | 99 | // @public (undocumented) 100 | export interface ITestExecutionContext { 101 | attach(name: string, buffer: Buffer, mimeType: string): void; 102 | log(format: any, ...args: any[]): void; 103 | } 104 | 105 | // @alpha (undocumented) 106 | export interface ITestReporter { 107 | onEnterStep(step: IStep): void; 108 | onExitStep(step: IStep, error?: Error): void; 109 | onFinish(errors: Error[]): void; 110 | } 111 | 112 | // @public @deprecated 113 | export function onFinish(f: () => void): void; 114 | 115 | // @public 116 | function pending_2(): void; 117 | export { pending_2 as pending } 118 | 119 | // @public @deprecated 120 | export function step(name: StepDefName, f: () => X): X; 121 | 122 | // @public (undocumented) 123 | export type StepDefName = StepName | string; 124 | 125 | // @public (undocumented) 126 | export class StepName { 127 | // @internal 128 | constructor(parts: string[], placeholders: string[]); 129 | // (undocumented) 130 | parts: string[]; 131 | // (undocumented) 132 | placeholders: string[]; 133 | // (undocumented) 134 | toString(): string; 135 | } 136 | 137 | // @public 138 | function test_2(name: string, f: () => X): X; 139 | 140 | // @public 141 | function test_2(nameParts: TemplateStringsArray, ...substitutions: any[]): (f: () => X) => X; 142 | export { test_2 as test } 143 | 144 | // @public (undocumented) 145 | export interface Thenable { 146 | // (undocumented) 147 | then(onFulfilled?: ((value: any) => any) | undefined | null, onRejected?: ((reason: any) => any) | undefined | null): Thenable; 148 | } 149 | 150 | // @public 151 | export function to(name: string, f: () => X): X; 152 | 153 | // @public 154 | export function to(nameParts: TemplateStringsArray, ...substitutions: any[]): (f?: () => X) => X; 155 | 156 | ``` 157 | -------------------------------------------------------------------------------- /api/prescript.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Acceptance test tool. 3 | * @packageDocumentation 4 | */ 5 | 6 | /// 7 | 8 | /** 9 | * Creates an Action Step to be performed at runtime. 10 | * @public 11 | */ 12 | export declare function action(name: string, f: ActionFunction): void; 13 | 14 | /** 15 | * Creates an Action Step to be performed at runtime. 16 | * @public 17 | */ 18 | export declare function action(nameParts: TemplateStringsArray, ...substitutions: any[]): (f?: ActionFunction) => void; 19 | 20 | /** 21 | * Deprecated: Makes the enclosing `step()` an Action Step. 22 | * @public 23 | * @deprecated Use `action(name, f)` or `action` template tag instead. 24 | */ 25 | export declare function action(f: ActionFunction): void; 26 | 27 | /** 28 | * @public 29 | */ 30 | export declare type ActionFunction = (state: Prescript.GlobalState, context: ITestExecutionContext) => void | Thenable; 31 | 32 | /** 33 | * @alpha 34 | */ 35 | export declare type ActionWrapper = (step: IStep, execute: () => Promise, state: Prescript.GlobalState, context: ITestExecutionContext) => Promise; 36 | 37 | /** 38 | * Deprecated. 39 | * @public 40 | * @deprecated Use `defer()` instead. 41 | */ 42 | export declare function cleanup(name: StepDefName, f: () => X): X; 43 | 44 | declare const _default: { 45 | test: typeof test_2; 46 | to: typeof to; 47 | action: typeof action; 48 | defer: typeof defer; 49 | pending: typeof pending_2; 50 | step: typeof step; 51 | cleanup: typeof cleanup; 52 | onFinish: typeof onFinish; 53 | getCurrentState: typeof getCurrentState; 54 | getCurrentContext: typeof getCurrentContext; 55 | getCurrentPrescriptionState: typeof getCurrentPrescriptionState; 56 | isPendingError: typeof isPendingError; 57 | }; 58 | export default _default; 59 | 60 | /** 61 | * Creates a Deferred Action Step, for, e.g., cleaning up resources. 62 | * @public 63 | */ 64 | export declare function defer(name: string, f: ActionFunction): void; 65 | 66 | /** 67 | * Creates a Deferred Action Step, for, e.g., cleaning up resources. 68 | * @public 69 | */ 70 | export declare function defer(nameParts: TemplateStringsArray, ...substitutions: any[]): (f?: ActionFunction) => void; 71 | 72 | /** 73 | * Returns the current action context object. 74 | * This allows library functions to hook into prescript’s current action context. 75 | * @public 76 | */ 77 | export declare function getCurrentContext(): ITestExecutionContext; 78 | 79 | /** 80 | * Returns a state object that exists only during prescription phase for each test. 81 | * @public 82 | */ 83 | export declare function getCurrentPrescriptionState(): Prescript.PrescriptionState; 84 | 85 | /** 86 | * Returns the current state object. 87 | * This allows library functions to hook into prescript’s state. 88 | * @public 89 | */ 90 | export declare function getCurrentState(): Prescript.GlobalState; 91 | 92 | /** 93 | * Configuration defined in the `prescript.config.js` file. 94 | * For more information, see the {@link https://taskworld.github.io/prescript/guide/config.html | advanced configuration guide}. 95 | * @public 96 | */ 97 | export declare interface IConfig { 98 | /** 99 | * You can setup an action wrapper that will wrap all action steps. It is like a middleware. 100 | * 101 | * @remarks 102 | * It can be used for various purposes: 103 | * - Enhance the error message / stack trace. 104 | * - Benchmarking and profiling. 105 | * - etc. 106 | * 107 | * @alpha 108 | */ 109 | wrapAction?: ActionWrapper; 110 | /** 111 | * Create a custom test reporter. 112 | * @remarks 113 | * It is very important that the reporter do not throw an error. 114 | * Otherwise, the behavior of prescript is undefined. 115 | * @param testModulePath - The path of the test file. 116 | * @alpha 117 | */ 118 | createTestReporter?(testModulePath: string, testName: string): ITestReporter; 119 | } 120 | 121 | /** 122 | * Marks the steps inside as independent 123 | * @public 124 | */ 125 | export declare function independent(f: () => X): X; 126 | 127 | /** 128 | * Checks if the provided Error object is a PendingError, which is 129 | * thrown by the `pending()` step. 130 | * 131 | * @param e - The error to check. 132 | * @public 133 | */ 134 | export declare function isPendingError(e: any): boolean; 135 | 136 | /** 137 | * @alpha 138 | */ 139 | export declare interface IStep { 140 | name: StepName; 141 | number?: string; 142 | children?: IStep[]; 143 | creator?: string; 144 | definition?: string; 145 | independent?: boolean; 146 | action?: ActionFunction; 147 | actionDefinition?: string; 148 | pending?: boolean; 149 | cleanup?: boolean; 150 | defer?: boolean; 151 | } 152 | 153 | /** 154 | * @public 155 | */ 156 | export declare interface ITestExecutionContext { 157 | /** 158 | * This adds a log message to the current step. 159 | * API is the same as `console.log()`. 160 | * Use this function instead of `console.log()` to not clutter the console output. 161 | * @param format - Format string, like `console.log()` 162 | * @param args - Arguments to be formatted. 163 | */ 164 | log(format: any, ...args: any[]): void; 165 | /** 166 | * This adds an attachment to the current step, such as screenshot, JSON result, etc. 167 | * @param name - Name of the attachment 168 | * @param buffer - Attachment content 169 | * @param mimeType - MIME type of the attachment (image/jpeg, text/plain, application/json...) 170 | */ 171 | attach(name: string, buffer: Buffer, mimeType: string): void; 172 | } 173 | 174 | /** 175 | * @alpha 176 | */ 177 | export declare interface ITestReporter { 178 | /** 179 | * Called when the test is finished. 180 | * @param errors - Errors that occurred during the test. 181 | * If there are no errors, this will be an empty array. 182 | * Note that pending tests are treated the same way as errors. 183 | * To check if an error object represents a pending test, use the {@link isPendingError} function. 184 | */ 185 | onFinish(errors: Error[]): void; 186 | /** 187 | * Called when the test step is being entered. 188 | * @param step - The test step that is being entered. 189 | */ 190 | onEnterStep(step: IStep): void; 191 | /** 192 | * Called when the test step is being exited. 193 | * @param step - The test step that is being exited. 194 | * @param error - The error that occurred during the test step. 195 | */ 196 | onExitStep(step: IStep, error?: Error): void; 197 | } 198 | 199 | /** 200 | * Deprecated. 201 | * @public 202 | * @deprecated Use `defer()` instead. 203 | */ 204 | export declare function onFinish(f: () => void): void; 205 | 206 | /** 207 | * Creates a Pending step to make the test end with pending state. 208 | * Useful for unfinished tests. 209 | * @public 210 | */ 211 | declare function pending_2(): void; 212 | export { pending_2 as pending } 213 | 214 | /** 215 | * Deprecated. 216 | * @public 217 | * @deprecated Use `to()` instead. 218 | */ 219 | export declare function step(name: StepDefName, f: () => X): X; 220 | 221 | /** 222 | * @public 223 | */ 224 | export declare type StepDefName = StepName | string; 225 | 226 | /** 227 | * @public 228 | */ 229 | export declare class StepName { 230 | parts: string[]; 231 | placeholders: string[]; 232 | /** 233 | * @internal 234 | */ 235 | constructor(parts: string[], placeholders: string[]); 236 | toString(): string; 237 | } 238 | 239 | /** 240 | * Creates a Test. 241 | * @public 242 | */ 243 | declare function test_2(name: string, f: () => X): X; 244 | 245 | /** 246 | * Creates a Test. 247 | * @public 248 | */ 249 | declare function test_2(nameParts: TemplateStringsArray, ...substitutions: any[]): (f: () => X) => X; 250 | export { test_2 as test } 251 | 252 | /** 253 | * @public 254 | */ 255 | export declare interface Thenable { 256 | then(onFulfilled?: ((value: any) => any) | undefined | null, onRejected?: ((reason: any) => any) | undefined | null): Thenable; 257 | } 258 | 259 | /** 260 | * Creates a Compound Test Step, which can contain child steps. 261 | * @public 262 | */ 263 | export declare function to(name: string, f: () => X): X; 264 | 265 | /** 266 | * Creates a Compound Test Step, which can contain child steps. 267 | * @public 268 | */ 269 | export declare function to(nameParts: TemplateStringsArray, ...substitutions: any[]): (f?: () => X) => X; 270 | 271 | export { } 272 | -------------------------------------------------------------------------------- /bin/prescript: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../cli') 3 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | const cli = require('./lib/cli').default 2 | const options = { boolean: ['d'] } 3 | cli(require('minimist')(process.argv.slice(2), options)) 4 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'prescript', 3 | description: 'an end-to-end test runner that sparks joy', 4 | base: '/prescript/', 5 | 6 | themeConfig: { 7 | nav: [ 8 | { text: 'Home', link: '/' }, 9 | { text: 'Guide', link: '/guide/' }, 10 | { text: 'GitHub', link: 'https://github.com/taskworld/prescript' } 11 | ], 12 | sidebar: [ 13 | { 14 | title: 'User guide', 15 | collapsable: false, 16 | children: [ 17 | '/guide/', 18 | '/guide/tutorial.md', 19 | '/guide/writing-tests.md', 20 | '/guide/cli.md', 21 | '/guide/api.md', 22 | '/guide/config.md', 23 | '/guide/tips.md' 24 | ] 25 | }, 26 | { 27 | title: 'More topics', 28 | collapsable: false, 29 | children: ['/guide/typings.md'] 30 | } 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/.vuepress/override.styl: -------------------------------------------------------------------------------- 1 | $accentColor = #4ab3b6; 2 | -------------------------------------------------------------------------------- /docs/.vuepress/style.styl: -------------------------------------------------------------------------------- 1 | :root { 2 | font-feature-settings: 'ss06' on, 'ss07' on; 3 | font-variant-numeric: tabular-nums; 4 | } 5 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | actionText: wtf? yet another testing framework?? → 4 | actionLink: /guide/ 5 | features: 6 | - title: Enjoy faster feedback 7 | details: prescript’s interactive development mode lets you run your test as you write it. Step by step. With hot-reloading, you don’t need to re-run the whole test scenario from the beginning just to test a small change. 8 | - title: Beautiful test reports 9 | details: prescript can generate a beautiful, human-readable test report, powered by Allure Framework. 10 | - title: Flexible 11 | details: Thanks to prescript’s modular nature, you can use it to test your web app with Puppeteer or Selenium. Or use it to test your mobile app, desktop app, CLI app, API app, or whatever app. 12 | - title: Built for scale 13 | details: Everybody knows E2E tests take a lot of time. prescript is engineered to allow tests to be run in parallel. 14 | - title: Maintainable 15 | details: prescript docs has some guideline, tips, and patterns to help you write effective tests. It also comes with TypeScript definitions. 16 | - title: It’s just JavaScript 17 | details: prescript’s API is just a handful of JavaScript function calls. You can use prescript’s APIs directly, or generate your tests from, e.g., data file or Cucumber features. 18 | --- 19 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This page will attempt to give you an **executive overview** and explain **why 4 | we created prescript** in the first place. It also explains some of our design 5 | choices. 6 | 7 | If you want to dive in right away, feel free to 8 | [skip this section](./tutorial.md). 9 | 10 | ## What’s that, friend? Yet another testing framework?? 11 | 12 | **prescript** is a test runner that’s designed to solve this one pain point: 13 | 14 | ::: tip PAIN POINT 15 | 16 | **When a test fails, we had to re-run our test all the way from the very 17 | beginning.** This makes writing E2E tests painful for a long multi-step 18 | scenarios (e.g. user onboarding tests). It’s especially frustrating when we need 19 | to debug part of a test scenario that’s flaky. 20 | 21 | ::: 22 | 23 | **prescript** solves this pain point by providing an **interactive development 24 | mode.** In this mode, if your test fails, prescript will pause your test where 25 | it failed. You can fix your test, hot reload the code in, and resume running, 26 | without having to re-run your test from the beginning. 27 | 28 | This makes **prescript** suitable for developing functional or E2E (end-to-end) 29 | tests, although you can use it for other kind of tests as well. 30 | 31 | ## Any other features? 32 | 33 | **prescript** has few other features: 34 | 35 | ### It’s easily _parallelizable…_ hmm, is that a word? 36 | 37 | E2E tests usually take quite a long time to run (compared to unit tests). 38 | 39 | To be scalable, we need to think about running these tests in parallel. 40 | 41 | prescript’s philosophy is **“1 test = 1 file.”** This makes it very easy to 42 | distribute tests across multiple machines. We at Taskworld run about 36 tests 43 | simultaneously. 44 | 45 | ::: tip NOTE 46 | 47 | **prescript** does not have a notion of ‘test suites’; the CLI only runs a 48 | single test. You need to write your own script in order to run multiple tests. 49 | This gives you complete control and thus maximum flexibility in orchestrating 50 | your tests. 51 | 52 | For simple projects, a simple shell script may suffice, i.e. 53 | `for I in tests/*.js; do yarn prescript "$I"; done`. For larger projects you can 54 | utilize orchestration tools provided by your platform, e.g. you may use 55 | [CircleCI’s split testing support](https://circleci.com/docs/2.0/parallelism-faster-jobs/) 56 | to run tests in parallel on CircleCI. 57 | 58 | ::: 59 | 60 | ### Beautiful report, thanks to someone else 61 | 62 | **prescript** integrates with 63 | [Allure Framework](https://docs.qameta.io/allure/): 64 | 65 | > Allure Framework is a flexible lightweight multi-language test report tool 66 | > that not only shows a very concise representation of what have been tested in 67 | > a neat web report form, but allows everyone participating in the development 68 | > process to extract maximum of useful information from everyday execution of 69 | > tests. 70 | 71 | ::: tip 72 | 73 | Click on the link above that says ‘Allure Framework’ to witness the beauty of 74 | the generated test reports! 75 | 76 | ::: 77 | 78 | ### We’ve been using it for 2 years internally 79 | 80 | We at [Taskworld](https://taskworld.com/) developed prescript and have been 81 | using it to test our production code for 2 years, so you may say it’s quite 82 | matured now. 83 | 84 | ### …but we don’t provide support… wait, this isn’t a feature? 85 | 86 | I wanted to be upfront about this: 87 | 88 | We open-sourced prescript primarily so that other people can benefit from our 89 | tool. However, we don’t plan to provide support for this tool outside of our use 90 | cases. And there may even be breaking changes where necessary (breaking changes 91 | for you are breaking changes for us, too). Therefore, please be prepared to help 92 | yourself. 93 | 94 | Please feel free to fork this tool and adapt it to your use cases. Pull requests 95 | are welcome. 96 | 97 | ## prescript is not a framework? 98 | 99 | **prescript** is not a framework; it’s ~~a people~~ a **test runner.** What did 100 | I mean by that? Let’s say you want to test a web application. You’ll need to 101 | have these components set up: 102 | 103 | 1. **Browser automation library.** This allows you to programmatically control 104 | a browser. Normally you would use 105 | [selenium-webdriver](https://www.npmjs.com/package/selenium-webdriver) or 106 | [puppeteer](https://www.npmjs.com/package/puppeteer). 107 | 108 | * **prescript** doesn’t care. You can use any library you want. That means you 109 | can also use prescript to test mobile apps, desktop apps, CLI tools, APIs, or 110 | anything. 111 | 112 | 2. **Test runner.** They provide a way to organize your test code and run it. 113 | **prescript** is a test runner. Different test runners have different ways 114 | of organizing your test code. For example: 115 | 116 | * [mocha](https://www.npmjs.com/package/mocha) lets your organize tests into 117 | “suites” and “tests”, using `describe` and `it` (or `suite` and `test`). 118 | * [cucumber](https://cucumber.io/) lets you organize your “executable 119 | specifications” into “features” and “scenarios” using the Gherkin syntax. 120 | * **prescript** lets you organize your test code into “tests” and “steps.” A 121 | step may contain sub-steps. 122 | 123 | 3. **Assertion library.** These provides you with an API to make assertions. 124 | This includes 125 | [Node.js’s `assert` module](https://nodejs.org/api/assert.html), 126 | [Chai](http://chaijs.com/api/bdd/), etc. Some test tools, such as 127 | [Jest](https://jestjs.io) provides its own built-in assertion library. 128 | prescript doesn’t. 129 | 130 | * **prescript** doesn’t care. You can use any library you want. 131 | 132 | 4. **Test reporter.** These components generate a beautiful test report for 133 | other humans to see (or for other computer programs to further process). 134 | 135 | * **prescript** does not come with its own reporter, but it integrates with 136 | [Allure Framework](https://docs.qameta.io/allure/). 137 | 138 | 5. **Test orchestrator.** You have many tests, but how do you run them? One by 139 | one, sequentially? In parallel, on the same process? On separate processes? 140 | On separate machines? On an on-demand auto-scaling cluster that runs tests 141 | in a containerized environment? In which order? If they fail, do you retry 142 | them? For how many times? Do you retry immediately, or retry at the end of 143 | the test batch? Should tests be aborted if too many tests failed in a row? 144 | That’s the job of the test orchestrator — it determines which tests to run 145 | when. 146 | 147 | * **prescript** doesn’t care. A prescript process only runs a single test 148 | once. That means you must write your own orchestrator. 149 | 150 | Several testing frameworks, such as [Cypress](https://www.cypress.io/), 151 | [Codecept](https://codecept.io/) and [Nightwatch](http://nightwatchjs.org/) 152 | comes with all of them integrated in a single package, but **prescript** is just 153 | a test runner. 154 | 155 | So, why this extreme **modularity**? 156 | 157 | 1. Being modular allows you to use prescript to test anything. As of writing, 158 | [Cypress](https://www.cypress.io/) is known to provide one of the best 159 | testing experiences for web apps, but it’s only for web apps. 160 | 161 | With prescript, [our testing experience](./tutorial.md) can be used for 162 | anything you may want to test. 163 | 164 | 2. Different projects have different test orchestration needs (as illustrated 165 | above), and it depends on the use case, the technology stack, the scale, and 166 | other constraints. 167 | 168 | Making prescript support all of them would make it unnecessarily complex. By 169 | not doing any orchestration, it reduces complexity (and maintenance burden) 170 | for us, and gives flexibility for you. 171 | 172 | So, think of **prescript** as a building block you can use to create great 173 | testing experience! 174 | -------------------------------------------------------------------------------- /docs/guide/allure1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/allure1.png -------------------------------------------------------------------------------- /docs/guide/allure2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/allure2.png -------------------------------------------------------------------------------- /docs/guide/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | Import `prescript` to access its APIs. 4 | 5 | ```js 6 | const { test, to, action, defer, pending, independent } = require('prescript') 7 | ``` 8 | 9 | ## `test` 10 | 11 | 12 | ```js 13 | test('Test name', () => { /* Define steps here */ }) 14 | test`Test name`(() => { /* Define steps here */ }) 15 | ``` 16 | 17 | 18 | Creates a **test.** 19 | 20 | ## `to` 21 | 22 | 23 | ```js 24 | to('Log in', () => { /* Define sub-steps here */ }) 25 | to`Log in`(() => { /* Define sub-steps here */ }) 26 | ``` 27 | 28 | 29 | Creates a **composite step.** 30 | 31 | ## `action(name, async (state, context) => { ... })` 32 | 33 | 34 | ```js 35 | action('Fill in username', async (state, context) => { /* Action here */ }) 36 | action`Fill in username`(async (state, context) => { /* Action here */ }) 37 | ``` 38 | 39 | 40 | Creates an **action step.** The function passed to `action()` will be called 41 | with these arguments: 42 | 43 | - **state** - The state object. In the beginning of the test, it is empty. Add 44 | things to this object to share state between steps and have it persisted 45 | between reloads. 46 | - **context** - The context object contains: 47 | - `log(...)` - Logs a message to the console. Use this instead of 48 | `console.log()` so that it doesn’t mess up console output. 49 | - `attach(name, buffer, mimeType)` — Attachs some binary output. For example, 50 | screenshots, raw API response. This will get written to the Allure report. 51 | 52 | ## `defer` 53 | 54 | 55 | ```js 56 | defer('Close browser', async (state, context) => { /* Action here */ }) 57 | defer`Close browser`(async (state, context) => { /* Action here */ }) 58 | ``` 59 | 60 | 61 | Creates a **deferred step** which queues an action to be run at the end of test. 62 | If the test reached this step, the action will be queued for running at the end, 63 | regardless of whether the test passed or not. 64 | 65 | A common pattern is to create a deferred step for closing the resource right 66 | after the action step that requested the resource. 67 | 68 | ```js 69 | action('Open browser', async state => { 70 | const options = { desiredCapabilities: { browserName: 'chrome' } } 71 | const browser = webdriverio.remote(options) 72 | state.browser = browser 73 | await browser.init() 74 | }) 75 | defer('Quit browser', state => { 76 | await state.browser.end() 77 | }) 78 | ``` 79 | 80 | ## `pending()` 81 | 82 | 83 | ```js 84 | pending() 85 | ``` 86 | 87 | 88 | Defines a **pending step.** When a pending step is run, it marks the test as 89 | pending. 90 | 91 | - When running in **development mode**, this causes the test to **pause**. 92 | - When run in **non-interactive mode**, prescript will **exit with code 2**. 93 | 94 | See more example how to use a pending step 95 | [here](https://prescript.netlify.com/guide/writing-tests.html#pending-steps). 96 | 97 | ## `getCurrentState()` 98 | 99 | Returns the current test state object. This method allows library functions to 100 | access the current state without requiring user to pass the `state` object all 101 | the way from the action. 102 | 103 | This can make writing tests more convenient, but treat this like a global 104 | variable — it introduces an _implicit_ runtime dependency from the caller to 105 | prescript’s internal state. 106 | 107 | ## `getCurrentContext()` 108 | 109 | Returns the current test state object. This method allows library functions to 110 | access functions such as `context.log()` and `context.attachment()` without 111 | requiring users to pass the `state` object all the way from the action. 112 | 113 | This can make writing tests more convenient, but treat this like a global 114 | variable — it introduces an _implicit_ runtime dependency from the caller to 115 | prescript’s internal state. 116 | 117 | ## `independent(() => { ... })` 118 | 119 | Steps directly inside this block will be run independently. For example, in the 120 | following code, actions A, B, and C would always be run even if preceding 121 | actions failed. However, action D will not be run if any previous actions 122 | failed. 123 | 124 | ```js 125 | independent(() => { 126 | action`A`(...) 127 | action`B`(...) 128 | action`C`(...) 129 | }) 130 | action`D`(...) 131 | ``` 132 | -------------------------------------------------------------------------------- /docs/guide/cli.md: -------------------------------------------------------------------------------- 1 | # Running a test (CLI) 2 | 3 | ## Development mode 4 | 5 | The **development mode** provides the **prescript interactive shell** which 6 | allows you to hot-reload the tests, or jump to steps in the test. 7 | 8 | To run a test in development mode: 9 | 10 | ``` 11 | yarn prescript tests/Filename.js -d 12 | ``` 13 | 14 | ## Non-interactive mode 15 | 16 | The **non-interactive mode** is for using in CI systems. The prescript 17 | interactive shell is not available here, but an Allure test result files may be 18 | generated in this mode. 19 | 20 | To run a test in non-interactive mode: 21 | 22 | ``` 23 | yarn prescript tests/Filename.js 24 | ``` 25 | 26 | ## When a test file contain multiple tests 27 | 28 | As introduced earlier, a prescript process only runs a single test once. 29 | However, for ease of use and flexibility, a test file may define multiple tests. 30 | Each test can be uniquely identified by its **test name.** 31 | 32 | You don’t have to specify the test name to run if there is only one test. 33 | However, if a test file declares multiple tests, prescript will refuse to run. 34 | In this case, you must explicitly specify the name of the test to run. 35 | 36 | ```bash 37 | yarn prescript [-d] tests/Filename.js "Test name" 38 | ``` 39 | 40 | You can list all tests using: 41 | 42 | ```bash 43 | yarn prescript tests/Filename.js --list 44 | yarn prescript tests/Filename.js --list --json 45 | ``` 46 | 47 | ## Exit code 48 | 49 | ::: tip NOTE 50 | 51 | Exit codes only apply to non-interactive mode 52 | 53 | ::: 54 | 55 | | Exit Code | Description | 56 | | --------- | ---------------------- | 57 | | 0 | Successful test | 58 | | 1 | Failed test | 59 | | 2 | Pending test | 60 | | 3 | Multiple tests defined | 61 | 62 | ## `ALLURE_ENV_*` environment variables 63 | 64 | In the non-interactive mode, when you specify the `ALLURE_ENV_*` environment 65 | variables, they will be added to the test report files. 66 | 67 | ## Running multiple tests 68 | 69 | prescript **by design** only runs a single test. This allows prescript to remain 70 | a simple tool. You must implement your own test orchestrator to fit it to your 71 | project needs, which may include: 72 | 73 | * Running all tests. 74 | * Running only a subset of tests. 75 | * Running tests sequentially. 76 | * Running tests in parallel. 77 | * Distributing tests to be run on a cluster of test runner machines. 78 | * Randomizing or specify the order of tests. 79 | * Retrying failed tests. 80 | * Aborting the test in case of too many failures. 81 | * Preparing environment variables before running tests. 82 | 83 | If prescript supported all of the above, it would make prescript unnecessarily 84 | complex. Therefore, prescript requires you to write your own test orchestrator. 85 | [It’s just a few lines of code!](https://github.com/taskworld/prescript/tree/78c094874fc3ae54107003ec976d211c106c330d/examples/testAll.js) 86 | -------------------------------------------------------------------------------- /docs/guide/config.md: -------------------------------------------------------------------------------- 1 | # Advanced configuration 2 | 3 | **prescript** will search from the test file’s directory for a 4 | `prescript.config.js` file. 5 | 6 | ## Side-effects 7 | 8 | Before prescript loads your test file, **prescript** will load the `prescript.config.js` file first. 9 | You can use this opportunity to require other supporting modules that may alter Node.js’ runtime behavior. 10 | 11 | Example usage: 12 | 13 | - [Config prescript to allow writing tests in TypeScript](./typings.md#writing-tests-in-typescript) 14 | 15 | ## `wrapAction` 16 | 17 | You can setup an action wrapper that will wrap all action steps. It is like a 18 | middleware. It can be used for various purposes: 19 | 20 | * Enhance the error message / stack trace. 21 | * Benchmarking and profiling. 22 | * etc. 23 | 24 | ```js 25 | exports.wrapAction = async (step, execute, state, context) => { 26 | // Stuff to do before executing the action. 27 | // Example: Record the start time. 28 | try { 29 | // This line MUST be present. Otherwise, the test action will not run. 30 | return await execute() 31 | } catch (e) { 32 | // If you catch an error here, you must re-throw it 33 | // (either the original error or a wrapper). 34 | // Otherwise all steps will pass! 35 | // 36 | // If you don’t intend to catch an error, you can remove the catch block. 37 | throw e 38 | } finally { 39 | // Stuff to do after executing the action. 40 | // Example: Record the time, or take screenshot of a browser after each step. 41 | } 42 | } 43 | ``` 44 | 45 | ## `createTestReporter` 46 | 47 | This allows you to create a custom reporter for your test, enabling custom reporting and integration with e.g. [OpenTelemetry](https://opentelemetry.io/). For more information, see the [type definition](https://github.com/taskworld/prescript/blob/master/api/prescript.d.ts#:~:text=*/-,createTestReporter,-) and [example](https://github.com/taskworld/prescript/tree/master/examples/reporters). 48 | -------------------------------------------------------------------------------- /docs/guide/fixed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/fixed.png -------------------------------------------------------------------------------- /docs/guide/non-interactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/non-interactive.png -------------------------------------------------------------------------------- /docs/guide/ouch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/ouch.png -------------------------------------------------------------------------------- /docs/guide/ouch2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/ouch2.png -------------------------------------------------------------------------------- /docs/guide/ouch3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/ouch3.png -------------------------------------------------------------------------------- /docs/guide/page-object-intellisense.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/page-object-intellisense.png -------------------------------------------------------------------------------- /docs/guide/reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/reload.png -------------------------------------------------------------------------------- /docs/guide/shell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/shell.png -------------------------------------------------------------------------------- /docs/guide/tagged.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/tagged.png -------------------------------------------------------------------------------- /docs/guide/tips.md: -------------------------------------------------------------------------------- 1 | # Tips for writing tests 2 | 3 | ## Use the Page Object pattern 4 | 5 | You can create classes to implement the Page Object pattern. At Taskworld, we 6 | use this pattern to help keep our test code more concise. We also get 7 | IntelliSense while we write our test code! 8 | 9 | ![Screenshot](./page-object-intellisense.png) 10 | 11 | You can read more about this pattern in 12 | [_Use page object pattern for more fluent and maintainable tests_](./writing-tests.md#use-page-object-pattern-for-more-fluent-and-maintainable-tests) 13 | section. 14 | 15 | ## Retrying 16 | 17 | Some steps may take more than 1 try to be able to be successfully carried out. 18 | You can create a `retry` function that will run your function for up to 3 times. 19 | 20 | ```js 21 | async function retry(f, n = 3) { 22 | let error 23 | for (let i = 0; i < n; i++) { 24 | try { 25 | return await f() 26 | } catch (e) { 27 | error = e 28 | } 29 | } 30 | throw error 31 | } 32 | ``` 33 | 34 | And use it like this: 35 | 36 | ```js 37 | action('Action', async state => { 38 | await retry(async () => { 39 | // ... your code ... 40 | }) 41 | }) 42 | ``` 43 | 44 | In some situations, e.g. waiting for changes to be propagated throughout the cluster, 45 | we may not be sure how many times we have to retry. 46 | Instead of retrying for a fixed number of time, 47 | we can also retry until it takes unreasonably long instead. 48 | 49 | ```js 50 | export default async function retryUntilTimeout(f, timeout = 15000) { 51 | const start = Date.now() 52 | let i = 0 53 | for (;;) { 54 | try { 55 | return await f(i++) 56 | } catch (error) { 57 | if (Date.now() - start > timeout) { 58 | throw error 59 | } else { 60 | const timeToWait = 100 + (Date.now() - start) / 10 // Fine-tune this 61 | await new Promise((r) => setTimeout(r, timeToWait)) 62 | } 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | ## Recovery mechanism 69 | 70 | Sometimes your tests may be interrupted by **“ENTER YOUR EMAIL TO SUBSCRIBE TO 71 | OUR NEWSLETTER”** or **“WE HAVE UPDATED OUR PRIVACY POLICY”** or similar modal 72 | dialogs. 73 | 74 | You may create an “attemptRecovery” function that will attempt to get you out of 75 | the situation. 76 | 77 | ```js 78 | async function attemptRecovery(state, context) { 79 | // dismiss GDPR modal, if exists 80 | // dismiss newsletter subscribe modal, if exists 81 | // dismiss Intercom modal, if exists 82 | // ... 83 | } 84 | ``` 85 | 86 | Then you can use it in conjection with `retry()` for more resilience. 87 | 88 | ```js 89 | action('Action', async (state, context) => { 90 | await retry(async () => { 91 | await attemptRecovery(state, context) 92 | // ... your code ... 93 | }) 94 | }) 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/guide/tutorial.md: -------------------------------------------------------------------------------- 1 | # A quick prescript primer (Tutorial) 2 | 3 | Ready for a **prescript**ed experience? You’ll need: 4 | 5 | * [Node.js](https://nodejs.org/en/) (at least version 8) 6 | * [Yarn](https://yarnpkg.com/en/) (make sure you have the latest version!) 7 | 8 | I assume you know how to use Node.js and Yarn. 9 | 10 | In this tutorial, we’ll learn: 11 | 12 | 1. How to create a test file. 13 | 2. How to use the **prescript interactive shell** to debug and fix a test file. 14 | 3. How to run the test and generate a report. 15 | 16 | We’re going to write a simple test using **prescript** and 17 | [Puppeteer](https://github.com/googlechrome/puppeteer/). It will go to 18 | `npmjs.com`, search for `prescript`, and verify that prescript is actually on 19 | npm. 20 | 21 | ## Test project setup 22 | 23 | * Let’s start by creating a new project: 24 | 25 | ```bash 26 | mkdir prescript-tutorial 27 | cd prescript-tutorial 28 | ``` 29 | 30 | * Install `prescript` and `puppeteer`: 31 | 32 | ```bash 33 | yarn add --dev prescript puppeteer 34 | ``` 35 | 36 | * Create a folder to store all our tests: 37 | 38 | ```bash 39 | mkdir tests 40 | ``` 41 | 42 | Now, our directory structure should look like this: 43 | 44 | ``` 45 | prescript-tutorial 46 | ├── tests/ 47 | ├── package.json 48 | └── yarn.lock 49 | ``` 50 | 51 | ## Create the test file 52 | 53 | * Create a file called `tests/npm-search.js` and paste in: 54 | 55 | ```js 56 | const puppeteer = require('puppeteer') 57 | const { test, action, defer } = require('prescript') 58 | const assert = require('assert') 59 | 60 | test('A quest for "prescript" on npm', () => { 61 | action('Open a web browser', async state => { 62 | state.browser = await puppeteer.launch({ headless: false }) 63 | state.page = await state.browser.newPage() 64 | }) 65 | defer('Close browser', async state => { 66 | state.browser.close() 67 | }) 68 | action('Go to npmjs.com', async state => { 69 | await state.page.goto('https://npmjs.com') 70 | }) 71 | action('Search for prescript', async state => { 72 | await state.page.type('[name="q"]', 'prescript') 73 | }) 74 | action('Verify that the description is correct', async state => { 75 | const description = await getText( 76 | state.page, 77 | '[class^="package-list-item__description"]' 78 | ) 79 | assert.equal(description, 'an end-to-end test runner that sparks joy') 80 | }) 81 | }) 82 | 83 | function getText(page, selector) { 84 | return page.evaluate( 85 | selector => document.querySelector(selector).textContent, 86 | selector 87 | ) 88 | } 89 | ``` 90 | 91 | - The `test()` function declares a **test**. A test can contain multiple 92 | **steps**. 93 | - The `action()` function declares an **action step**, which represents a test 94 | action to be executed. 95 | - The `defer()` function declares a **deferred step**, which represents an 96 | action to be performed at the end of the test (similar to 97 | [Go’s defer statement](https://tour.golang.org/flowcontrol/12)), and is useful 98 | for cleaning up and closing resources. 99 | 100 | ::: tip THE `state` VARIABLE 101 | 102 | You may notice there’s a variable called `state`. You must put everything that’s 103 | shared between steps in this variable. 104 | 105 | **Do not create variables to hold the state of your tests.** You may ask, “why 106 | can’t we just use local variables, i.e. putting `let browser` at the top of the 107 | file?” Well, you’ll see why soon… ;) 108 | 109 | ::: 110 | 111 | ## Run the test in development mode 112 | 113 | Now we have the test, let’s run it! 114 | 115 | * Run the test in **development mode** using `yarn prescript -d `: 116 | 117 | ```bash 118 | yarn prescript -d tests/npm-search.js 119 | ``` 120 | 121 | This will drop you into a **prescript interactive shell:** 122 | 123 | ![Screenshot](./shell.png) 124 | 125 | * Type in **`continue`** and press Enter. 126 | 127 | This will run the test to its completion (or until it hits an error). 128 | 129 | Let’s see how it goes… 130 | 131 | ![Screenshot](./ouch.png) 132 | 133 | Oops, there’s an error! 134 | 135 | From the terminal output, we see that **the test failed at step 5** (‘Verify 136 | that the description is correct’)… This is one benefit of breaking your test 137 | into discrete steps — the tool can tell you exactly which step failed. 138 | 139 | It’s time to debug! 140 | 141 | ## Debugging the test 142 | 143 | Now, since we are in **interactive development mode**, the test is **paused** 144 | here to let you inspect what’s going on. (In many other tools, the browser would 145 | have closed immediately.) 146 | 147 | Now we can take a look at the browser: 148 | 149 | ![Screenshot](./what-happened.png) 150 | 151 | Hm… 🤔 what’s going on here???…………Oh! 😲 There it is! 💡 152 | 153 | We found that in **step 4 (‘Search for prescript’), we typed the search text but 154 | didn’t press Enter.** That’s why we stay at the same page... 155 | 156 | 157 | ```js {2} 158 | action('Search for prescript', async state => { 159 | await state.page.type('[name="q"]', 'prescript') 160 | }) 161 | ``` 162 | 163 | 164 | So, we can say that **the fault in step 4 caused the failure in step 5.** 165 | 166 | ::: tip FAULT vs FAILURE 167 | 168 | **prescript** tells you which step caused the test to fail. But the **failure** 169 | may be caused by a **fault** in a prior step. 170 | 171 | ::: 172 | 173 | ## Fixing the test 174 | 175 | Let’s go ahead and fix it… 176 | 177 | 178 | 179 | * Update the test so that we press Enter key after typing in “prescript”: 180 | 181 | ```js {3} 182 | action('Search for prescript', async state => { 183 | await state.page.type('[name="q"]', 'prescript') 184 | await state.page.keyboard.press('Enter') 185 | }) 186 | ``` 187 | 188 | * Don’t forget to save the file. 189 | 190 | 191 | 192 | ## Hot-reloading the test 193 | 194 | Now we’re going to hot-reload our test file. 195 | 196 | * Come back to **prescript interactive shell**. 197 | 198 | * Type in **`reload`** (or `r`) and press Enter. 199 | 200 | The test plan will be reloaded, but all your test state will remain intact. 201 | 202 | ![Screenshot](./reload.png) 203 | 204 | ::: tip NOTE 205 | 206 | That’s why we need to use prescript-provided `state` object — its contents are 207 | preserved across reloads! Had we used local variables (i.e. using 208 | `let browser, page` instead of `state.browser` and `state.page`), our 209 | newly-loaded test code wouldn’t be able to access the `browser` and `page` 210 | created in the previously-loaded test code, because it’s — well — local to it. 211 | That’s how hot-reloading is made possible in prescript. 212 | 213 | ::: 214 | 215 | ## Resuming the test 216 | 217 | When you **`reload`** your test, prescript will put you before the failed step. 218 | 219 | But as aforementioned, the fault in **step 4** caused the failure in **step 5**. 220 | That means to recover, we must go back to **step 4** and continue from there. 221 | 222 | * Jump to step 4 by typing **`jump 4`** (or `j 4`) and press Enter. 223 | 224 | * Continue running the test by typing **`continue`** (or `c`) and press Enter. 225 | 226 | ::: tip NOTE 227 | 228 | That’s why you need to break down your test into discrete steps — this allows 229 | prescript to take control of the way your test code is executed, thus letting 230 | you jump around and resume execution in the middle of your test. 231 | 232 | ::: 233 | 234 | Let’s see how it goes… 235 | 236 | ![Screenshot](./ouch2.png) 237 | 238 | Waaaa—! 239 | 240 | It failed again! 241 | 242 | Let’s look at the browser to see what happened... 243 | 244 | ![Screenshot](./what-happened2.png) 245 | 246 | This time, the search box contains the text ‘prescriptprescript’. Since we 247 | **retried** step 4, it got executed twice. That means the word ‘prescript’ got 248 | typed into the search box twice! 249 | 250 | ::: tip LESSON 251 | 252 | When reloading or jumping, make sure to roll the state back before continuing! 253 | 254 | ::: 255 | 256 | ## Rolling the state back 257 | 258 | Ok, let’s try again. 259 | 260 | * Jump back to step 4 by typing **`jump 4`** (or `j 4`) and press Enter. 261 | 262 | * **In the browser, manually delete the text in the search box.** Also click the 263 | Back button so that we are back to npm’s homepage. **This effectively brings 264 | us to the known state before step 4 is first executed.** 265 | 266 | * Continue running the test by typing **`continue`** (or `c`) and press Enter. 267 | 268 | ![Screenshot](./ouch3.png) 269 | 270 | Ouch… It failed again, at **step 5** (‘Verify that the description is correct’)! 271 | 272 | But this time, it seems that the browser is showing the correct result. 273 | 274 | This is because **step 4 finished immediately after we press Enter.** It didn’t 275 | wait for the search results to load or anything; it just hit Enter and moved on 276 | to **step 5** right away. So, **step 5** tried to verify the search result 277 | immediately, when it’s not available yet. Of course, this would fail the test. 278 | 279 | ## Fault containment 280 | 281 | As you can see, a **fault** in one step can cause a **failure** in subsequent 282 | steps. This can lead to tests that are hard to debug. To write tests that can be 283 | easily debugged, it’s important to follow the **fault containment principle…** 284 | 285 | ::: tip FAULT CONTAINMENT PRINCIPLE 286 | 287 | Make sure each step verifies the outcome of its own action. 288 | 289 | For example, 290 | 291 | * Step 1 (‘Go to npmjs.com’) should wait for the web page to load, and verify 292 | that `npmjs.com`’s home page is indeed displayed (instead of e.g. 502 pages). 293 | * Step 2 (‘Search for prescript’) should wait for the search results to load, 294 | and verify that the search results are available before moving on. 295 | 296 | ::: 297 | 298 | 299 | 300 | * Update the test so that each step waits and checks for the outcome of its own 301 | actions (Don’t forget to save the file!): 302 | 303 | ```js {3,8} 304 | action('Go to npmjs.com', async state => { 305 | await state.page.goto('https://npmjs.com') 306 | await state.page.waitForSelector('#app main') 307 | }) 308 | action('Search for prescript', async state => { 309 | await state.page.type('[name="q"]', 'prescript') 310 | await state.page.keyboard.press('Enter') 311 | await state.page.waitForSelector('[class^="search__packageList"]') 312 | }) 313 | ``` 314 | 315 | ## Seeing the test pass 316 | 317 | Hopefully, we’ve fixed everything now. 318 | 319 | * In **prescript interactive shell**, **`reload`**, **`jump 4`**, and **`continue`**. 320 | 321 | 322 | 323 | Now, our test should pass ;) 324 | 325 | ![Screenshot](./fixed.png) 326 | 327 | * Exit prescript by typing **`exit`** and press Enter. 328 | 329 | Now you can see how having an **interactive development mode** can help you 330 | debug a failed test with tighter feedback loop. 331 | 332 | ## Running the test in non-interactive mode 333 | 334 | Having an **interactive development mode** at hand is great, but we also want to 335 | run our tests as part of a continuous integration build process. That’s where we 336 | should run the test in non-interactive mode. 337 | 338 | * Run the test in **non-interactive mode** using `yarn prescript `: 339 | 340 | ```bash 341 | yarn prescript tests/npm-search.js 342 | ``` 343 | 344 | This will run the test to its completion, or exits with an error code if the 345 | test failed. The prescript interactive shell is not available in this mode. 346 | 347 | ![Screenshot](./non-interactive.png) 348 | 349 | ## Generating Allure test reports 350 | 351 | As introduced in the previous page, prescript can generate a beautiful test 352 | report using Allure. 353 | 354 | * Install the **Allure** command-line tool by following the 355 | [installation steps in its docs](https://docs.qameta.io/allure/#_installing_a_commandline). 356 | 357 | * Set the environment variable `ALLURE_TEST_RESULTS` for the current shell: 358 | 359 | ```bash 360 | # Linux, macOS 361 | export ALLURE_RESULTS_DIR=allure-results 362 | 363 | # Windows 364 | SET ALLURE_RESULTS_DIR=allure-results 365 | ``` 366 | 367 | * Run the test in **non-interactive mode**: 368 | 369 | ```bash 370 | yarn prescript tests/npm-search.js 371 | ``` 372 | 373 | You should see a directory called **allure-results** created. Inside it, you 374 | should see XML files. 375 | 376 | ::: tip NOTE 377 | 378 | If you use Git, don’t forget to add `allure-results` to your `.gitignore`. 379 | 380 | ::: 381 | 382 | ### View the test report 383 | 384 | As you can see, **prescript** generates XML files to be used by Allure to 385 | generate a test report. 386 | 387 | * To view the test report: 388 | 389 | ```bash 390 | allure serve allure-results 391 | ``` 392 | 393 | ![Screenshot](./allure1.png) 394 | 395 | ![Screenshot](./allure2.png) 396 | 397 | Nice. 398 | 399 | ### Generate the test report 400 | 401 | The `allure serve` command lets you view the report in the browser. But you may 402 | want to generate the test report and upload them for others to see. 403 | 404 | * To generate the test report: 405 | 406 | ```bash 407 | allure generate test-results 408 | ``` 409 | 410 | A directory `allure-report` should pop up in your project directory. You can 411 | then publish your generated `allure-report` and share with your colleague. 412 | 413 | ::: tip NOTE 414 | 415 | If you use Git, don’t forget to add `allure-report` to your `.gitignore`. 416 | 417 | ::: 418 | 419 | ## Conclusion 420 | 421 | 1. We learned how to write test in prescript using the `test`, `action`, and 422 | `defer` APIs. 423 | 424 | 2. We learned how to use the **prescript interactive shell** to debug a test. 425 | 426 | 3. We learned how to run tests in **interactive development mode** and 427 | **non-interactive mode**. 428 | 429 | 4. We learned how to generate an **test report** using Allure. 430 | 431 | The next section will dive into more details about writing tests in prescript. 432 | There, you will learn how to, for example, define **nested steps**, or use a 433 | page object pattern for a **cleaner test code.** 434 | -------------------------------------------------------------------------------- /docs/guide/typing-complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/typing-complete.png -------------------------------------------------------------------------------- /docs/guide/typing-hint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/typing-hint.png -------------------------------------------------------------------------------- /docs/guide/typing-nocomplete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/typing-nocomplete.png -------------------------------------------------------------------------------- /docs/guide/typing-unknown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/typing-unknown.png -------------------------------------------------------------------------------- /docs/guide/typings.md: -------------------------------------------------------------------------------- 1 | # VS Code JavaScript IntelliSense and/or TypeScript Support 2 | 3 | Out of the box, **prescript** comes with a type declaration file. This means it 4 | will work with 5 | [VS Code IntelliSense for JavaScript](https://code.visualstudio.com/docs/languages/javascript) 6 | right away, even if you are using just JavaScript. 7 | 8 | ![Screenshot](./typing-hint.png) 9 | 10 | ## Writing tests in TypeScript 11 | 12 | If you want to write tests in TypeScript, you can [configure prescript](./config.md#side-effects) to inject `ts-node` into the Node.js runtime. 13 | This will let you write tests in TypeScript. 14 | 15 | 1. Install `ts-node`. 16 | 17 | 2. Create `prescript.config.js` with this contents: 18 | 19 | ```js 20 | require('ts-node/register/transpile-only') 21 | ``` 22 | 23 | ## Declaring the type of prescript `state` 24 | 25 | The type of `state` variable, passed to your action functions in `action()` and 26 | `defer()` APIs defaults to an `unknown` type. 27 | 28 | ![Screenshot](./typing-unknown.png) 29 | 30 | That means that by default you don’t get autocompletion when writing actions: 31 | 32 | ![Screenshot](./typing-nocomplete.png) 33 | 34 | To fix this, you can define the type of your prescript `state` by following 35 | these steps: 36 | 37 | 1. If you are not using TypeScript, 38 | [set up a JavaScript project in VS Code](https://code.visualstudio.com/docs/languages/javascript#_javascript-projects-jsconfigjson) 39 | by creating a `jsconfig.json` file at the root of your project. 40 | 41 | ```json 42 | { 43 | "compilerOptions": { "target": "ES6" }, 44 | "include": ["tests"], 45 | "exclude": ["node_modules", "**/node_modules/*"] 46 | } 47 | ``` 48 | 49 | Reload VS Code after creating this file to make sure that VS Code picks up 50 | the configuration file. 51 | 52 | 2. Create a file `state.d.ts` which will contain the type definition of your 53 | state. It should declare an interface `GlobalState` in a global namespace 54 | `Prescript`, thus triggering 55 | [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html). 56 | Here’s an example: 57 | 58 | ```typescript 59 | import * as puppeteer from 'puppeteer' 60 | 61 | declare global { 62 | namespace Prescript { 63 | interface GlobalState { 64 | browser: puppeteer.Browser 65 | page: puppeteer.Page 66 | } 67 | } 68 | } 69 | 70 | export {} 71 | ``` 72 | 73 | Anything you add to the `GlobalState` interface inside `Prescript` namespace 74 | will show up in the type of the `state` variable. 75 | 76 | 3. Now you have IntelliSense for your test state! 77 | 78 | ![Screenshot](./typing-complete.png) 79 | -------------------------------------------------------------------------------- /docs/guide/what-happened.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/what-happened.png -------------------------------------------------------------------------------- /docs/guide/what-happened2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskworld/prescript/f905d20933e2fcf3e29c779c0a82a16386e684fe/docs/guide/what-happened2.png -------------------------------------------------------------------------------- /docs/guide/writing-tests.md: -------------------------------------------------------------------------------- 1 | # Writing tests in prescript 2 | 3 | ## Conceptual model 4 | 5 | In prescript, your **“test”** (scenario) is separated into multiple discrete 6 | **“steps”.** This results in a **“test plan”** which looks like this: 7 | 8 | ```text 9 | Test: Sucessful password reset 10 | ├── Step 1. Open browser 11 | ├── Deferred Step 2. Close browser 12 | ├── Step 3. Request password reset 13 | │ ├── Step 3.1. Go to forgot password page 14 | │ ├── Step 3.2. Enter the email 15 | │ └── Step 3.3. Submit the form 16 | ├── Step 4. Open the password reset link in email 17 | │ ├── Step 4.1. Check the email 18 | │ ├── Step 4.2. Open the reset password email 19 | │ └── Step 4.3. Click the reset password link in email 20 | ├── Step 5. Reset password 21 | │ ├── Step 5.1. Enter the new password 22 | │ └── Step 5.2. Submit the form 23 | ├── Step 6. Login with the new credentials 24 | └── Step 7. I should be in the workspace 25 | ``` 26 | 27 | There are **4 types of steps**: 28 | 29 | 1. **Action steps** performs some kind of action. If an action fail, all 30 | subsequent steps will be aborted. 31 | 32 | 2. **Deferred steps** queues an action to be run at the end of the test. It 33 | will be run when the test is completed or terminated due to an error in one 34 | of the action steps. 35 | 36 | 3. **Composite steps** allows you to group steps together, creating a hierarchy 37 | of steps. 38 | 39 | 4. **Pending steps** marks the test as pending. Its behavior is equivalent to a 40 | failed action step (that is, it aborts the tests and future steps are not 41 | run). But instead of the test being marked as “FAILED” (exit code 1), it 42 | will be marked as “PENDING” (exit code 2). This is useful when your test is 43 | not complete, or when (temporarily) disabling a test. 44 | 45 | ## Programming model 46 | 47 | We write our test in JavaScript. The above test can be represented in 48 | **prescript** as JavaScript code like this: 49 | 50 | 51 | ```js 52 | const { test, to, action, defer } = require('prescript') 53 | test('Sucessful password reset', () => { 54 | action('Open browser', async state => { ... }) 55 | defer('Close browser', async state => { ... }) 56 | to('Request password reset', () => { 57 | action('Go to forgot password page', async state => { ... }) 58 | action('Enter the email', async state => { ... }) 59 | action('Submit the form', async state => { ... }) 60 | }) 61 | to('Open the password reset link in email', () => { 62 | action('Check the email', async state => { ... }) 63 | action('Open the reset password email', async state => { ... }) 64 | action('Click the reset password link in email', async state => { ... }) 65 | }) 66 | to('Reset password', () => { 67 | action('Enter the new password', async state => { ... }) 68 | action('Submit the form', async state => { ... }) 69 | }) 70 | action('Login with the new credentials', () => { ... }) 71 | action('I should be in the workspace', () => { ... }) 72 | }) 73 | ``` 74 | 75 | 76 | Since the test file is a JavaScript file, you can also generate actions 77 | indirectly (see the Page Object section down below for an example). 78 | 79 | ## Execution phases 80 | 81 | When you run **prescript**, there are 2 phases that your code gets executed: 82 | 83 | * **Prescripting phase.** In this phase, your test code is first executed to 84 | determine what tests are available, including the steps in each test. This 85 | results in a **test plan** being generated. Code outside the `action()` and 86 | `defer()` blocks are executed in this phase. 87 | 88 | ::: warning 89 | 90 | All the logic that’s executed during the prescripting phase **must be 91 | deterministic** to allow the test code to be safely hot-reloaded. 92 | 93 | ::: 94 | 95 | * **Running phase.** In this phase, prescript executes the actions according to 96 | the test plan generated from the prescripting phase. 97 | 98 | ## A basic test 99 | 100 | Use `test()` to define a test. Each test must have a unique name. 101 | 102 | Use `action()` to create an **action step**. You should pass a function that 103 | either returns a Promise \(async action\) or returns nothing \(sync action\). 104 | 105 | ```javascript 106 | // Basic addition.js 107 | const { test, to, action } = require('prescript') 108 | const assert = require('assert') 109 | const Calculator = require('../lib/Calculator') 110 | 111 | test('Basic addition', () => { 112 | action('Initialize the calculator', state => { 113 | state.calculator = new Calculator() 114 | }) 115 | action('Enter 50 into the calculator', state => { 116 | state.calculator.enter(50) 117 | }) 118 | action('Enter 70 into the calculator', state => { 119 | state.calculator.enter(70) 120 | }) 121 | action('Press add', state => { 122 | state.calculator.add() 123 | }) 124 | action('Stored result must be 120', state => { 125 | assert.equal(state.calculator.result, 120) 126 | }) 127 | }) 128 | ``` 129 | 130 | ## Use composite steps to group related steps together 131 | 132 | Multiple actions may be grouped using `to()`. This creates a **composite step.** 133 | 134 | ```js 135 | // Basic addition.js 136 | test('Basic addition', () => { 137 | action('Initialize the calculator', state => { 138 | state.calculator = new Calculator() 139 | }) 140 | to('Calculate 50 + 70', () => { 141 | action('Enter 50 into the calculator', state => { 142 | state.calculator.enter(50) 143 | }) 144 | action('Enter 70 into the calculator', state => { 145 | state.calculator.enter(70) 146 | }) 147 | action('Press add', state => { 148 | state.calculator.add() 149 | }) 150 | }) 151 | action('Stored result must be 120', state => { 152 | assert.equal(state.calculator.result, 120) 153 | }) 154 | }) 155 | ``` 156 | 157 | ## Use [page object pattern](http://martinfowler.com/bliki/PageObject.html) for more fluent and maintainable tests 158 | 159 | Upgrading to this pattern is very beneficial when there are many test cases that 160 | reuses the same logic. 161 | 162 | For more, I highly recommend reading 163 | [_Selenium: 7 Things You Need To Know_](https://www.lucidchart.com/techblog/2015/07/21/selenium-7-things-you-need-to-know-2/), 164 | even for people who don’t use Selenium. The article contains a lot of great tips 165 | for anyone who writes end-to-end tests. And these tips applies even if you’re 166 | using something else (e.g. Puppeteer, Appium, etc) to test your app. 167 | 168 | In this pattern, **instead of creating steps directly in our test code,** we 169 | write a library that creates the test steps for us. 170 | 171 | Here’s the previous test case, using the page object pattern. 172 | 173 | ```javascript 174 | // Basic addition.js 175 | const { test } = require('prescript') 176 | const CalculatorTester = require('../test-lib/CalculatorTester') 177 | 178 | test('Basic addition', () => { 179 | new CalculatorTester().add(50, 70).resultMustBe(120) 180 | }) 181 | ``` 182 | 183 | **Now our test is much shorter.** All of our logic related to controlling the 184 | calculator is now centralized in `CalculatorTester`. This means 185 | `CalculatorTester` can be used from many tests, leading to a drier code. 186 | 187 | ```javascript 188 | // CalculatorTester.js 189 | const { to, action } = require('prescript') 190 | const Calculator = require('../lib/Calculator') 191 | const assert = require('assert') 192 | 193 | module.exports = class CalculatorTester { 194 | constructor() { 195 | action('Initialize the calculator', state => { 196 | state.calculator = new Calculator() 197 | }) 198 | } 199 | 200 | /** 201 | * Creates a step that makes the calculator add `a` and `b` together. 202 | * @param {number} a 203 | * @param {number} b 204 | */ 205 | add(a, b) { 206 | to`Calculate ${a} + ${b}`(() => { 207 | this.enter(a) 208 | .enter(b) 209 | .pressAdd() 210 | }) 211 | return this 212 | } 213 | 214 | /** 215 | * Creates a step that asserts the state of the calculator. 216 | * @param {number} n 217 | */ 218 | resultMustBe(n) { 219 | action`Stored result must be ${n}`(state => { 220 | assert.equal(state.calculator.result, n) 221 | }) 222 | return this 223 | } 224 | 225 | /** 226 | * Creates a step that enters a number into the calculator. 227 | * @param {number} number 228 | */ 229 | enter(number) { 230 | action`Enter ${number} into the calculator`(state => { 231 | state.calculator.enter(number) 232 | }) 233 | return this 234 | } 235 | 236 | /** 237 | * Creates a step that presses the add button on the calculator. 238 | * @param {number} number 239 | */ 240 | pressAdd() { 241 | action('Press add', state => { 242 | state.calculator.add() 243 | }) 244 | return this 245 | } 246 | } 247 | ``` 248 | 249 | ::: warning 250 | 251 | Due to the way it’s written, our `CalculatorTester` currently hardcoded to work 252 | with `state.calculator`. That means it’s capable of working with only a single 253 | calculator, no matter how many `CalculatorTester` instances you create. This 254 | works fine if you are only testing 1 Calculator instance at the same time. 255 | 256 | However sometimes, you might have to test 2 things running simultaneously. For 257 | us at [Taskworld](https://taskworld.com), we build a collaborative realtime app. 258 | So, sometimes we need to run multiple instances of the browser at the same time, 259 | to verify that updates from A gets sent to browser B in real-time. 260 | 261 | For this, you can adjust your page object to receive the state key in which it 262 | will operate on. 263 | 264 | ```js {3, 5} 265 | module.exports = class CalculatorTester { 266 | constructor(stateKey = 'calculator') { 267 | this._stateKey = stateKey 268 | action('Initialize the calculator', state => { 269 | state[this._stateKey] = new Calculator() 270 | }) 271 | } 272 | ``` 273 | 274 | ::: 275 | 276 | ::: tip THE TAGGED TEMPLATE LITERAL SYNTAX 277 | 278 | When creating steps that involves variables, you can use a 279 | [**tagged template literals**](http://exploringjs.com/es6/ch_template-literals.html) 280 | syntax, as can be seen in the example above: 281 | 282 | 283 | ```js 284 | action`Stored result must be ${n}`(state => { 285 | assert.equal(state.calculator.result, n) 286 | }) 287 | ``` 288 | 289 | 290 | When run, the substitutions (`${n}`) will be color-coded. This is an indication 291 | that suggests you not to use the substituted text when search the source code 292 | for the step you want. 293 | 294 | ![Screenshot](./tagged.png) 295 | 296 | ::: 297 | 298 | ## Pending steps 299 | 300 | Pending steps are quite useful. When a pending step is run, it marks the test as 301 | **pending.** When run in non-interactive mode, prescript will exit with code 2. 302 | 303 | ### When developing tests 304 | 305 | When developing a new test, it’s useful to put `pending()` in the test, because: 306 | 307 | 1. This explicitly marks the test as **unfinished.** 308 | 309 | 2. When running in **development mode**, this causes the test to **pause**. 310 | Otherwise, the deferred step (‘Close browser’) would be run right away, 311 | closing the browser. 312 | 313 | ```js {13} 314 | const puppeteer = require('puppeteer') 315 | const { test, action, defer, pending } = require('prescript') 316 | const assert = require('assert') 317 | 318 | test('A quest for "prescript" on npm', () => { 319 | action('Open a web browser', async state => { 320 | state.browser = await puppeteer.launch({ headless: false }) 321 | state.page = await state.browser.newPage() 322 | }) 323 | defer('Close browser', async state => { 324 | state.browser.close() 325 | }) 326 | pending() 327 | }) 328 | ``` 329 | 330 | ### Temporarily disabling a test 331 | 332 | If some tests are getting in the way and you need to ship your stuff, you can 333 | promise to “fix it later” by calling `pending()` if a certain time isn’t 334 | reached. 335 | 336 | ```js 337 | // I will fix it later, I promise! 338 | if (Date.now() < Date.parse('2018-10-01T10:00:00Z')) pending() 339 | ``` 340 | -------------------------------------------------------------------------------- /examples/action-wrapper/lib/stats.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | runCount: 0 3 | } 4 | -------------------------------------------------------------------------------- /examples/action-wrapper/prescript.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('../..').IConfig} */ 2 | module.exports = { 3 | async wrapAction(step, execute, state, context) { 4 | const start = process.hrtime() 5 | try { 6 | return await execute() 7 | } finally { 8 | const diff = process.hrtime(start) 9 | context.log('Step took %s nanoseconds', diff[0] * 1e9 + diff[1]) 10 | require('./lib/stats').runCount++ 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/action-wrapper/tests/Wrapper-test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const { action } = require('../../..') 3 | 4 | action('Action 1', () => {}) 5 | action('Action 2', () => {}) 6 | action('Action 3', () => {}) 7 | action('Check count', () => { 8 | assert.equal(require('../lib/stats').runCount, 3) 9 | }) 10 | -------------------------------------------------------------------------------- /examples/attachment/tests/Attachment-test.js: -------------------------------------------------------------------------------- 1 | const { action } = require('../../..') 2 | 3 | action('Attachment', (state, context) => { 4 | context.attach('Report', Buffer.from('

ALL IS GOOD!

'), 'text/html') 5 | }) 6 | -------------------------------------------------------------------------------- /examples/calculator/lib/Calculator.js: -------------------------------------------------------------------------------- 1 | module.exports = class Calculator { 2 | constructor () { 3 | this._stack = [ ] 4 | } 5 | enter (number) { 6 | this._stack.push(number) 7 | } 8 | add () { 9 | this._stack.push(this._stack.pop() + this._stack.pop()) 10 | } 11 | get result () { 12 | return this._stack[this._stack.length - 1] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/calculator/test-lib/CalculatorTester.js: -------------------------------------------------------------------------------- 1 | const { to, action } = require('../../..') 2 | const Calculator = require('../lib/Calculator') 3 | const assert = require('assert') 4 | 5 | module.exports = class CalculatorTester { 6 | constructor() { 7 | action('Initialize the calculator', state => { 8 | state.calculator = new Calculator() 9 | }) 10 | } 11 | 12 | /** 13 | * Creates a step that makes the calculator add `a` and `b` together. 14 | * @param {number} a 15 | * @param {number} b 16 | */ 17 | add(a, b) { 18 | to`Calculate ${a} + ${b}`(() => { 19 | this.enter(a) 20 | .enter(b) 21 | .pressAdd() 22 | }) 23 | return this 24 | } 25 | 26 | /** 27 | * Creates a step that asserts the state of the calculator. 28 | * @param {number} n 29 | */ 30 | resultMustBe(n) { 31 | action`Stored result must be ${n}`(state => { 32 | assert.equal(state.calculator.result, n) 33 | }) 34 | return this 35 | } 36 | 37 | /** 38 | * Creates a step that enters a number into the calculator. 39 | * @param {number} number 40 | */ 41 | enter(number) { 42 | action`Enter ${number} into the calculator`(state => { 43 | state.calculator.enter(number) 44 | }) 45 | return this 46 | } 47 | 48 | /** 49 | * Creates a step that presses the add button on the calculator. 50 | * @param {number} number 51 | */ 52 | pressAdd() { 53 | action('Press add', state => { 54 | state.calculator.add() 55 | }) 56 | return this 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /examples/calculator/tests/Basic addition (flat).js: -------------------------------------------------------------------------------- 1 | const { test, to, action } = require('../../..') 2 | const assert = require('assert') 3 | const Calculator = require('../lib/Calculator') 4 | 5 | test('Basic addition', () => { 6 | action('Initialize the calculator', state => { 7 | state.calculator = new Calculator() 8 | }) 9 | action('Enter 50 into the calculator', state => { 10 | state.calculator.enter(50) 11 | }) 12 | action('Enter 70 into the calculator', state => { 13 | state.calculator.enter(70) 14 | }) 15 | action('Press add', state => { 16 | state.calculator.add() 17 | }) 18 | action('Stored result must be 120', state => { 19 | assert.equal(state.calculator.result, 120) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /examples/calculator/tests/Basic addition (flat, old api).js: -------------------------------------------------------------------------------- 1 | const { step, action } = require('../../..') 2 | const assert = require('assert') 3 | const Calculator = require('../lib/Calculator') 4 | 5 | step('Initialize the calculator', () => { 6 | action(state => { 7 | state.calculator = new Calculator() 8 | }) 9 | }) 10 | step('Enter 50 into the calculator', () => { 11 | action(state => { 12 | state.calculator.enter(50) 13 | }) 14 | }) 15 | step('Enter 70 into the calculator', () => { 16 | action(state => { 17 | state.calculator.enter(70) 18 | }) 19 | }) 20 | step('Press add', () => { 21 | action(state => { 22 | state.calculator.add() 23 | }) 24 | }) 25 | step('Stored result must be 120', () => { 26 | action(state => { 27 | assert.equal(state.calculator.result, 120) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /examples/calculator/tests/Basic addition (nested).js: -------------------------------------------------------------------------------- 1 | const { test, to, action } = require('../../..') 2 | const assert = require('assert') 3 | const Calculator = require('../lib/Calculator') 4 | 5 | test('Basic addition', () => { 6 | action('Initialize the calculator', state => { 7 | state.calculator = new Calculator() 8 | }) 9 | to('Calculate 50 + 70', () => { 10 | action('Enter 50 into the calculator', state => { 11 | state.calculator.enter(50) 12 | }) 13 | action('Enter 70 into the calculator', state => { 14 | state.calculator.enter(70) 15 | }) 16 | action('Press add', state => { 17 | state.calculator.add() 18 | }) 19 | }) 20 | action('Stored result must be 120', state => { 21 | assert.equal(state.calculator.result, 120) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /examples/calculator/tests/Basic addition (nested, old api).js: -------------------------------------------------------------------------------- 1 | const { step, action } = require('../../..') 2 | const assert = require('assert') 3 | const Calculator = require('../lib/Calculator') 4 | 5 | step('Initialize the calculator', () => { 6 | action(state => { 7 | state.calculator = new Calculator() 8 | }) 9 | }) 10 | step('Calculate 50 + 70', () => { 11 | step('Enter 50 into the calculator', () => { 12 | action(state => { 13 | state.calculator.enter(50) 14 | }) 15 | }) 16 | step('Enter 70 into the calculator', () => { 17 | action(state => { 18 | state.calculator.enter(70) 19 | }) 20 | }) 21 | step('Press add', () => { 22 | action(state => { 23 | state.calculator.add() 24 | }) 25 | }) 26 | }) 27 | step('Stored result must be 120', () => { 28 | action(state => { 29 | assert.equal(state.calculator.result, 120) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /examples/calculator/tests/Basic addition (nested, tagged template literal syntax).js: -------------------------------------------------------------------------------- 1 | const { test, to, action } = require('../../..') 2 | const assert = require('assert') 3 | const Calculator = require('../lib/Calculator') 4 | 5 | test`Basic addition`(() => { 6 | action`Initialize the calculator`(state => { 7 | state.calculator = new Calculator() 8 | }) 9 | to`Calculate 50 + 70`(() => { 10 | action`Enter 50 into the calculator`(state => { 11 | state.calculator.enter(50) 12 | }) 13 | action`Enter 70 into the calculator`(state => { 14 | state.calculator.enter(70) 15 | }) 16 | action`Press add`(state => { 17 | state.calculator.add() 18 | }) 19 | }) 20 | action`Stored result must be 120`(state => { 21 | assert.equal(state.calculator.result, 120) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /examples/calculator/tests/Basic addition (page object).js: -------------------------------------------------------------------------------- 1 | const { test } = require('../../..') 2 | const CalculatorTester = require('../test-lib/CalculatorTester') 3 | test('Basic addition', () => { 4 | new CalculatorTester().add(50, 70).resultMustBe(120) 5 | }) 6 | -------------------------------------------------------------------------------- /examples/calculator/tests/Basic addition (page object, old api).js: -------------------------------------------------------------------------------- 1 | const CalculatorTester = require('../test-lib/CalculatorTester') 2 | new CalculatorTester().add(50, 70).resultMustBe(120) 3 | -------------------------------------------------------------------------------- /examples/calculator/tests/Basic addition (singleton api).js: -------------------------------------------------------------------------------- 1 | const { test, action, getCurrentState, getCurrentContext } = require('../../..') 2 | const assert = require('assert') 3 | const Calculator = require('../lib/Calculator') 4 | 5 | test('Basic addition', () => { 6 | action('Initialize the calculator', () => { 7 | getCurrentState().calculator = new Calculator() 8 | getCurrentContext().log() 9 | }) 10 | action('Enter 50 into the calculator', () => { 11 | getCurrentState().calculator.enter(50) 12 | }) 13 | action('Enter 70 into the calculator', () => { 14 | getCurrentState().calculator.enter(70) 15 | }) 16 | action('Press add', () => { 17 | getCurrentState().calculator.add() 18 | }) 19 | action('Stored result must be 120', () => { 20 | assert.equal(getCurrentState().calculator.result, 120) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /examples/defer/tests/Defer-test.js: -------------------------------------------------------------------------------- 1 | const { test, to, action, defer } = require('../../..') 2 | const assert = require('assert') 3 | 4 | test('Deferring actions', () => { 5 | action('Initialize', state => { 6 | state.initialized = true 7 | state.tornDown = false 8 | }) 9 | action('Verify initialzed', state => { 10 | assert.equal(state.initialized, true) 11 | }) 12 | defer('Deferred teardown step', state => { 13 | state.tornDown = true 14 | }) 15 | action('Verify not torn down yet', state => { 16 | assert.equal(state.tornDown, false) 17 | }) 18 | defer('Verify torn down', state => { 19 | assert.equal(state.tornDown, true) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /examples/independent/tests/Independent-test.fail: -------------------------------------------------------------------------------- 1 | const { test, action, independent } = require('../../..') 2 | 3 | independent(() => { 4 | test('Test 1', () => { 5 | action('Test 1 action', () => { 6 | throw new Error('a') 7 | }) 8 | }) 9 | 10 | test('Test 2', () => { 11 | action('Test 2 action', () => {}) 12 | }) 13 | 14 | test('Test 3', () => { 15 | action('Test 3 action', () => { 16 | throw new Error('b') 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /examples/multiple/tests/tests.js: -------------------------------------------------------------------------------- 1 | const { test, action } = require('../../..') 2 | 3 | test('Test 1', () => { 4 | action('Test 1 action', () => {}) 5 | }) 6 | 7 | test('Test 2', () => { 8 | action('Test 2 action', () => {}) 9 | }) 10 | 11 | test('Test 3', () => { 12 | action('Test 3 action', () => {}) 13 | }) 14 | -------------------------------------------------------------------------------- /examples/pending/tests/Implicit-pending-composite-step.js: -------------------------------------------------------------------------------- 1 | // This is a pending test. 2 | // Pending tests exit with code=2 and should handle this exit code appropriately. 3 | require('../../..').to`Implement later`() 4 | -------------------------------------------------------------------------------- /examples/pending/tests/Implicit-pending-test.js: -------------------------------------------------------------------------------- 1 | // This is a pending test. 2 | // Pending tests exit with code=2 and should handle this exit code appropriately. 3 | require('../../..').action`Do this`() 4 | -------------------------------------------------------------------------------- /examples/pending/tests/Pending-test.js: -------------------------------------------------------------------------------- 1 | // This is a pending test. 2 | // Pending tests exit with code=2 and should handle this exit code appropriately. 3 | require('../../..').pending() 4 | -------------------------------------------------------------------------------- /examples/prescription-state/tests/PrescriptionState-test.js: -------------------------------------------------------------------------------- 1 | const { test, action, getCurrentPrescriptionState } = require('../../..') 2 | const assert = require('assert') 3 | 4 | test('getCurrentPrescriptionState', () => { 5 | foo().check(1) 6 | foo().check(2) 7 | foo().check(3) 8 | foo().check(4) 9 | }) 10 | 11 | function foo() { 12 | const prescriptionState = getCurrentPrescriptionState() 13 | const counter = (prescriptionState.counter = 14 | 1 + (prescriptionState.counter || 0)) 15 | return { 16 | check(value) { 17 | action`Prescribed counter (${counter}) should equal ${value}`( 18 | async () => { 19 | assert.equal(counter, value) 20 | } 21 | ) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/regression/fixtures/Issue27-AnsiColorTestFixture.js: -------------------------------------------------------------------------------- 1 | const { action } = require('../../..') 2 | const expect = require('expect') 3 | 4 | action('This is a failing test', async () => { 5 | expect(1).toEqual(2) 6 | }) 7 | -------------------------------------------------------------------------------- /examples/regression/tests/Issue27.js: -------------------------------------------------------------------------------- 1 | const { action } = require('../../..') 2 | const assert = require('assert') 3 | const expect = require('expect') 4 | const fs = require('fs') 5 | const glob = require('glob') 6 | const { execFileSync } = require('child_process') 7 | 8 | action('Clean the results directory', async () => { 9 | execFileSync('rm', ['-rf', 'tmp/issue27-allure-results']) 10 | }) 11 | 12 | for (let i = 1; i <= 3; i++) { 13 | action('Run test (it should fail)', async () => { 14 | let failed = false 15 | try { 16 | execFileSync( 17 | './bin/prescript', 18 | [require.resolve('../fixtures/Issue27-AnsiColorTestFixture.js')], 19 | { 20 | env: { 21 | ...process.env, 22 | ALLURE_RESULTS_DIR: 'tmp/issue27-allure-results', 23 | ALLURE_SUITE_NAME: 'prescript-regression-issue27', 24 | FORCE_COLOR: '1' 25 | } 26 | } 27 | ) 28 | } catch (error) { 29 | failed = true 30 | } 31 | assert(failed, 'Expected prescript command to fail') 32 | }) 33 | } 34 | 35 | action('Verify that there are JSON allure results generated', async () => { 36 | const files = glob.sync('*.json', { cwd: 'tmp/issue27-allure-results' }) 37 | assert(files.length > 0, 'Expected to find JSON files') 38 | }) 39 | 40 | action('Verify the test results JSON have consistent historyId', async () => { 41 | const files = glob.sync('*-result.json', { 42 | cwd: 'tmp/issue27-allure-results' 43 | }) 44 | const historyIds = Array.from( 45 | new Set( 46 | files.map( 47 | f => 48 | JSON.parse(fs.readFileSync(`tmp/issue27-allure-results/${f}`, 'utf8')) 49 | .historyId 50 | ) 51 | ) 52 | ) 53 | expect(historyIds).toHaveLength(1) 54 | }) 55 | 56 | action('Generate an allure-report', async () => { 57 | execFileSync('yarn', [ 58 | 'allure', 59 | 'generate', 60 | '--clean', 61 | ...['--output', 'tmp/issue27-allure-report'], 62 | 'tmp/issue27-allure-results' 63 | ]) 64 | assert(fs.existsSync('tmp/issue27-allure-report'), 'Expected report to exist') 65 | }) 66 | 67 | action('Verify that there is a test case file', async () => { 68 | const files = glob.sync('*.json', { 69 | cwd: 'tmp/issue27-allure-report/data/test-cases' 70 | }) 71 | assert(files.length > 0, 'Expected test case JSON files to exist') 72 | }) 73 | -------------------------------------------------------------------------------- /examples/regression/tests/Issue34.js: -------------------------------------------------------------------------------- 1 | const { action } = require('../../..') 2 | const assert = require('assert') 3 | const expect = require('expect') 4 | const fs = require('fs') 5 | const glob = require('glob') 6 | const { execFileSync } = require('child_process') 7 | 8 | action('Clean the results directory', async () => { 9 | execFileSync('rm', ['-rf', 'tmp/issue34-allure-results']) 10 | }) 11 | 12 | action('Run test', async () => { 13 | execFileSync( 14 | './bin/prescript', 15 | [require.resolve('../../calculator/tests/Basic addition (flat).js')], 16 | { 17 | env: { 18 | ...process.env, 19 | ALLURE_RESULTS_DIR: 'tmp/issue34-allure-results', 20 | ALLURE_ENV_MEOW: 'meow' 21 | } 22 | } 23 | ) 24 | }) 25 | 26 | action('Generate an allure-report', async () => { 27 | execFileSync('yarn', [ 28 | 'allure', 29 | 'generate', 30 | '--clean', 31 | ...['--output', 'tmp/issue34-allure-report'], 32 | 'tmp/issue34-allure-results' 33 | ]) 34 | assert(fs.existsSync('tmp/issue34-allure-report'), 'Expected report to exist') 35 | }) 36 | 37 | action('Verify that parameters are logged', async () => { 38 | const [file] = glob.sync('tmp/issue34-allure-report/data/test-cases/*.json') 39 | assert(file, 'Expected test case JSON files to exist') 40 | const testcase = JSON.parse(fs.readFileSync(file)) 41 | expect(testcase.parameters).toEqual([ 42 | { name: 'ALLURE_ENV_MEOW', value: 'meow' } 43 | ]) 44 | }) 45 | -------------------------------------------------------------------------------- /examples/reporters/fixtures/ExampleTestFixture.js: -------------------------------------------------------------------------------- 1 | const { to, action, defer } = require('../../..') 2 | const expect = require('expect') 3 | 4 | to('Do something', () => { 5 | action('Do this', async () => {}) 6 | action('Do that', async () => {}) 7 | to('Do the other thing', () => { 8 | action('Do these', async () => {}) 9 | action('Do those', async () => {}) 10 | action('Do deez dooz', async () => {}) 11 | }) 12 | }) 13 | 14 | action('This is a passing step', async () => {}) 15 | defer('This is a deferred step', async () => {}) 16 | action('This is a failing step', async () => { 17 | throw new Error('This is a failing step') 18 | }) 19 | -------------------------------------------------------------------------------- /examples/reporters/fixtures/prescript.config.js: -------------------------------------------------------------------------------- 1 | const { execFileSync } = require('child_process') 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | /** @type {import('../../..').IConfig} */ 6 | module.exports = { 7 | createTestReporter: (testModulePath, testName) => { 8 | const events = [] 9 | const relativePath = path.relative(process.cwd(), testModulePath) 10 | let depth = 0 11 | const log = text => { 12 | const prefix = '| '.repeat(depth) + '* ' 13 | events.push(prefix + text) 14 | } 15 | log(`createTestReporter: ${relativePath}, testName=${testName}`) 16 | depth++ 17 | return { 18 | onEnterStep(step) { 19 | log(`onEnterStep: ${step.name}`) 20 | depth++ 21 | }, 22 | onExitStep(step, error) { 23 | depth-- 24 | log(`onExitStep: ${step.name}, error=${error}`) 25 | }, 26 | onFinish(errors) { 27 | depth-- 28 | log(`onFinish: errors.length=${errors.length}`) 29 | execFileSync('mkdir', ['-p', 'tmp/reporter']) 30 | fs.writeFileSync('tmp/reporter/events.txt', events.join('\n')) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/reporters/tests/Reporter-test.js: -------------------------------------------------------------------------------- 1 | const { action } = require('../../..') 2 | const expect = require('expect') 3 | const fs = require('fs') 4 | const { spawnSync } = require('child_process') 5 | 6 | const expectedSequence = ` 7 | * createTestReporter: examples/reporters/fixtures/ExampleTestFixture.js, testName=[implicit test] 8 | | * onEnterStep: [implicit test] 9 | | | * onEnterStep: Do something 10 | | | | * onEnterStep: Do this 11 | | | | * onExitStep: Do this, error=undefined 12 | | | | * onEnterStep: Do that 13 | | | | * onExitStep: Do that, error=undefined 14 | | | | * onEnterStep: Do the other thing 15 | | | | | * onEnterStep: Do these 16 | | | | | * onExitStep: Do these, error=undefined 17 | | | | | * onEnterStep: Do those 18 | | | | | * onExitStep: Do those, error=undefined 19 | | | | | * onEnterStep: Do deez dooz 20 | | | | | * onExitStep: Do deez dooz, error=undefined 21 | | | | * onExitStep: Do the other thing, error=undefined 22 | | | * onExitStep: Do something, error=undefined 23 | | | * onEnterStep: This is a passing step 24 | | | * onExitStep: This is a passing step, error=undefined 25 | | | * onEnterStep: This is a failing step 26 | | | * onExitStep: This is a failing step, error=Error: This is a failing step 27 | | * onExitStep: [implicit test], error=undefined 28 | | * onEnterStep: This is a deferred step 29 | | * onExitStep: This is a deferred step, error=undefined 30 | * onFinish: errors.length=1 31 | `.trim() 32 | 33 | action('Run test', async () => { 34 | spawnSync('./bin/prescript', [ 35 | require.resolve('../fixtures/ExampleTestFixture.js') 36 | ]) 37 | }) 38 | 39 | action('Check generated sequence', async () => { 40 | const actualSequence = fs 41 | .readFileSync('tmp/reporter/events.txt', 'utf8') 42 | .trim() 43 | expect(actualSequence).toEqual(expectedSequence) 44 | }) 45 | -------------------------------------------------------------------------------- /examples/testAll.js: -------------------------------------------------------------------------------- 1 | // Checks that all examples are working. 2 | const glob = require('glob') 3 | const chalk = require('chalk') 4 | let failures = 0 5 | 6 | console.log('Checking if all examples work!') 7 | 8 | function runTest(file, testName, { onMulti } = {}) { 9 | const title = `${file}${testName ? ` => ${testName}` : ''}` 10 | try { 11 | require('child_process').execFileSync('./bin/prescript', [ 12 | file, 13 | ...(testName ? [testName] : []) 14 | ]) 15 | console.log(chalk.bgGreen.bold(' OK '), title) 16 | } catch (e) { 17 | if (e.status === 2) { 18 | console.log(chalk.bgCyan.bold(' .. '), title) 19 | } else if (onMulti && e.status === 3) { 20 | console.log(chalk.bgYellow.bold(' ** '), title) 21 | onMulti() 22 | } else { 23 | console.log(chalk.bgRed.bold(' NG '), title) 24 | console.log(e.output.join('')) 25 | failures += 1 26 | } 27 | } 28 | } 29 | 30 | for (const file of glob.sync('examples/*/tests/**/*.js')) { 31 | runTest(file, null, { 32 | onMulti() { 33 | const testNamesJSON = require('child_process').execFileSync( 34 | './bin/prescript', 35 | [file, '--list', '--json'] 36 | ) 37 | const testNames = JSON.parse(testNamesJSON) 38 | for (const name of testNames) { 39 | runTest(file, name) 40 | } 41 | } 42 | }) 43 | } 44 | 45 | if (failures) { 46 | process.exitCode = 1 47 | } 48 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['ts', 'tsx', 'js'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | testMatch: ['**/src/**/*.test.+(ts|tsx|js)'], 7 | testEnvironment: 'node' 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prescript", 3 | "version": "0.55555555.0", 4 | "description": "Object-oriented acceptance test tool", 5 | "author": "Thai Pangsakulyanont ", 6 | "license": "MIT", 7 | "main": "./lib/singletonApi.js", 8 | "types": "./api/prescript.d.ts", 9 | "bin": "./bin/prescript", 10 | "files": [ 11 | "lib", 12 | "bin", 13 | "api/prescript.d.ts", 14 | "cli.js" 15 | ], 16 | "scripts": { 17 | "lint": "eslint . --ext .ts,.js", 18 | "build": "tsc", 19 | "prepare": "rm -rf lib && yarn build", 20 | "test": "jest", 21 | "test-examples": "node examples/testAll.js", 22 | "docs:dev": "vuepress dev docs", 23 | "docs:build": "vuepress build docs" 24 | }, 25 | "dependencies": { 26 | "@types/cosmiconfig": "^5.0.3", 27 | "allure-js-commons": "^2.10.0", 28 | "chalk": "^2.4.1", 29 | "co": "^4.6.0", 30 | "cosmiconfig": "^5.0.7", 31 | "error-stack-parser": "^2.0.1", 32 | "indent-string": "^3.0.0", 33 | "invariant": "^2.2.2", 34 | "minimist": "^1.2.0", 35 | "ms": "^0.7.2", 36 | "vorpal": "^1.11.4" 37 | }, 38 | "devDependencies": { 39 | "@microsoft/api-documenter": "^7.13.44", 40 | "@microsoft/api-extractor": "^7.18.7", 41 | "@types/indent-string": "^3.0.0", 42 | "@types/invariant": "^2.2.29", 43 | "@types/jest": "^22.2.3", 44 | "@types/ms": "^0.7.30", 45 | "@types/node": "^16.0.0", 46 | "@typescript-eslint/parser": "^2.2.0", 47 | "allure-commandline": "^2.13.0", 48 | "eslint": "^6.3.0", 49 | "eslint-config-prettier": "^6.2.0", 50 | "eslint-plugin-prettier": "^3.1.0", 51 | "glob": "^7.1.2", 52 | "jest": "^22.4.3", 53 | "prettier": "^1.18.2", 54 | "ts-jest": "^22.4.4", 55 | "typescript": "^5.3.3", 56 | "vuepress": "^1.4.1" 57 | }, 58 | "volta": { 59 | "node": "16.20.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/PendingError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This error is thrown by prescript when a `pending()` step is executed. 3 | * @public 4 | */ 5 | export class PendingError extends Error { 6 | public __prescriptPending = true 7 | 8 | constructor() { 9 | super('[pending]') 10 | this.name = 'PendingError' 11 | } 12 | } 13 | 14 | /** 15 | * Checks if the provided Error object is a PendingError, which is 16 | * thrown by the `pending()` step. 17 | * 18 | * @param e - The error to check. 19 | * @public 20 | */ 21 | export function isPendingError(e: any) { 22 | return !!(e && e.__prescriptPending) 23 | } 24 | -------------------------------------------------------------------------------- /src/StepName.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import invariant from 'invariant' 3 | 4 | export function parse(string: string): StepName { 5 | const placeholders: string[] = [] 6 | const stringedParts = string.replace(/`([^`]+?)`/g, (a, x) => { 7 | placeholders.push(x) 8 | return '\0' 9 | }) 10 | return new StepName(stringedParts.split('\0'), placeholders) 11 | } 12 | 13 | export function coerce(stepNameOrString: StepName | string): StepName { 14 | if (typeof stepNameOrString === 'string') { 15 | return parse(stepNameOrString) 16 | } 17 | if (typeof stepNameOrString === 'object' && stepNameOrString) { 18 | const stepName = stepNameOrString 19 | invariant( 20 | stepName.parts, 21 | 'Expected step name object to have `parts` property' 22 | ) 23 | invariant( 24 | stepName.placeholders, 25 | 'Expected step name object to have `placeholders` property' 26 | ) 27 | return stepName 28 | } 29 | throw invariant( 30 | false, 31 | 'Step name should be a string or a tagged `named` literal.' 32 | ) 33 | } 34 | 35 | /** 36 | * Creates a step name. Use this as tagged template string. 37 | */ 38 | export function named(parts: TemplateStringsArray, ...placeholders: string[]) { 39 | return new StepName([...parts], placeholders) 40 | } 41 | 42 | export function format(stepName: StepName | string, { colors = true } = {}) { 43 | if (typeof stepName === 'string') { 44 | return stepName 45 | } 46 | const { parts, placeholders } = stepName 47 | const resultParts: string[] = [] 48 | for (let i = 0; i < parts.length; i++) { 49 | resultParts.push(parts[i]) 50 | if (placeholders[i]) { 51 | resultParts.push( 52 | colors ? chalk.cyan(String(placeholders[i])) : `‘${placeholders[i]}’` 53 | ) 54 | } 55 | } 56 | return resultParts.join('') 57 | } 58 | 59 | /** 60 | * @public 61 | */ 62 | export class StepName { 63 | /** 64 | * @internal 65 | */ 66 | constructor(public parts: string[], public placeholders: string[]) {} 67 | 68 | toString() { 69 | return format(this, { colors: false }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import ms from 'ms' 2 | import path from 'path' 3 | import util from 'util' 4 | import chalk from 'chalk' 5 | import indentString from 'indent-string' 6 | 7 | import createUI from './createUI' 8 | import * as singleton from './singleton' 9 | import walkSteps from './walkSteps' 10 | import isStepExist from './isStepExist' 11 | import createReporter from './createReporter' 12 | import prettyFormatStep from './prettyFormatStep' 13 | import createTestIterator from './createTestIterator' 14 | import { 15 | ITestIterator, 16 | IStep, 17 | ITestLoadLogger, 18 | ITestExecutionContext, 19 | IIterationListener 20 | } from './types' 21 | import { StepName } from './StepName' 22 | import { createConsoleLogger } from './loadTestModule' 23 | import { state } from './globalState' 24 | import cosmiconfig from 'cosmiconfig' 25 | import { resolveConfig, ResolvedConfig } from './configuration' 26 | import singletonAllureInstance from './singletonAllureInstance' 27 | import currentActionContext from './currentActionContext' 28 | import { isPendingError } from './PendingError' 29 | 30 | function main(args) { 31 | const testModulePath = require('fs').realpathSync(args._[0]) 32 | const result = cosmiconfig('prescript').searchSync( 33 | path.dirname(testModulePath) 34 | ) 35 | const config = resolveConfig((result && result.config) || {}) 36 | const requestedTestName = args._[1] || null 37 | 38 | if (args.l || args['list']) { 39 | listTests(testModulePath, { json: !!args.json }) 40 | return 41 | } 42 | 43 | console.log( 44 | chalk.bold.magenta('# prescript'), 45 | 'v' + require('../package').version 46 | ) 47 | console.log() 48 | 49 | const dev = args.d || args['dev'] 50 | if (args['inspect']) { 51 | require('inspector').open() 52 | } 53 | if (dev) { 54 | runDevelopmentMode(testModulePath, requestedTestName, config) 55 | } else { 56 | runNonInteractiveMode(testModulePath, requestedTestName, config) 57 | } 58 | } 59 | 60 | function listTests(testModulePath: string, options: { json: boolean }) { 61 | const writer = options.json 62 | ? (() => { 63 | let written = false 64 | return { 65 | start() { 66 | process.stdout.write('[ ') 67 | }, 68 | test(name: string) { 69 | if (written) process.stdout.write(', ') 70 | process.stdout.write(JSON.stringify(name) + '\n') 71 | written = true 72 | }, 73 | finish() { 74 | process.stdout.write(']\n') 75 | } 76 | } 77 | })() 78 | : { 79 | start() {}, 80 | test(name: string) { 81 | console.log(name) 82 | }, 83 | finish() {} 84 | } 85 | writer.start() 86 | singleton.loadTests( 87 | () => { 88 | require(testModulePath) 89 | }, 90 | { 91 | logger: { 92 | step() {}, 93 | test(name: StepName) { 94 | writer.test(String(name)) 95 | } 96 | } 97 | } 98 | ) 99 | writer.finish() 100 | } 101 | 102 | function runDevelopmentMode( 103 | testModulePath: string, 104 | requestedTestName: string | null, 105 | config: ResolvedConfig 106 | ) { 107 | const tester: ITestIterator = createTestIterator(createLogVisitor()) 108 | const ui = createUI() 109 | let previousResult: { stepNumber: string | null; error?: Error } | null = null 110 | 111 | function loadTest() { 112 | ui.testLoadStarted() 113 | try { 114 | const tests = singleton 115 | .loadTests(() => { 116 | require(testModulePath) 117 | }) 118 | .filter(filterTest(requestedTestName)) 119 | if (!tests.length) { 120 | throw new Error('To tests found.') 121 | } 122 | tester.setTest(tests[0]) 123 | ui.testLoadCompleted(tests) 124 | } catch (e) { 125 | ui.testLoadError(e) 126 | } 127 | } 128 | 129 | function clearModuleCache() { 130 | const keysToRemove = Object.keys(require.cache).filter(shouldRemove) 131 | ui.moduleUncacheStarted() 132 | for (const key of keysToRemove) { 133 | delete require.cache[key] 134 | ui.moduleUncached(key) 135 | } 136 | 137 | function shouldRemove(filePath) { 138 | const components = filePath.split(path.sep) 139 | return ( 140 | !components.includes('node_modules') && 141 | !path.relative(process.cwd(), filePath).startsWith('..') 142 | ) 143 | } 144 | } 145 | 146 | loadTest() 147 | tester.begin() 148 | let canceled = false 149 | 150 | ui.developmentModeStarted({ 151 | tester, 152 | getState() { 153 | return state 154 | }, 155 | getCurrentStepNumber() { 156 | return tester.getCurrentStepNumber() 157 | }, 158 | getCurrentStep() { 159 | return tester.getCurrentStep() 160 | }, 161 | getPreviousResult() { 162 | return previousResult 163 | }, 164 | forEachStep(callback) { 165 | walkSteps(tester.getTest(), callback) 166 | }, 167 | reload() { 168 | clearModuleCache() 169 | loadTest() 170 | const reloadResult: { 171 | jump?: { target: string | null; success: boolean } 172 | } = {} 173 | if (previousResult) { 174 | const jumpTarget = previousResult.stepNumber 175 | tester.begin(jumpTarget) 176 | const next = tester.getCurrentStepNumber() 177 | reloadResult.jump = { 178 | target: jumpTarget, 179 | success: !!next 180 | } 181 | } else { 182 | tester.begin() 183 | } 184 | return reloadResult 185 | }, 186 | continue() { 187 | return runTestWhileConditionMet() 188 | }, 189 | nextStep() { 190 | return runNextStep() 191 | }, 192 | jumpTo(stepNumber) { 193 | tester.begin(stepNumber) 194 | previousResult = null 195 | }, 196 | async runTo(stepNumber) { 197 | if (!isStepExist(tester.getTest(), stepNumber)) { 198 | console.log(chalk.red('Error: step number is not exists in test')) 199 | return 200 | } 201 | const matchCondition = () => { 202 | const currentStep = tester.getCurrentStepNumber() 203 | const targetStep = stepNumber 204 | return `${currentStep}.`.startsWith(`${targetStep}.`) 205 | } 206 | await runTestWhileConditionMet(() => !matchCondition()) 207 | }, 208 | cancel() { 209 | canceled = true 210 | } 211 | }) 212 | 213 | async function runTestWhileConditionMet(condition?) { 214 | const executeCondition = () => 215 | typeof condition === 'function' ? condition() : true 216 | while (executeCondition() && !tester.isDone()) { 217 | if (await runNextStep()) break 218 | if (canceled) { 219 | ui.onUserInterrupted() 220 | canceled = false 221 | break 222 | } 223 | } 224 | } 225 | 226 | async function runNextStep() { 227 | let error 228 | const stepNumber = tester.getCurrentStepNumber() 229 | await runNext(tester, config, state, e => { 230 | error = e 231 | }) 232 | previousResult = { stepNumber, error } 233 | return error 234 | } 235 | } 236 | 237 | function createFilteredLogger( 238 | requestedTestName: string | null 239 | ): ITestLoadLogger { 240 | const logger = createConsoleLogger() 241 | let active = !requestedTestName 242 | return { 243 | test(name) { 244 | if (requestedTestName) active = String(name) === requestedTestName 245 | if (active) logger.test(name) 246 | }, 247 | step(step) { 248 | if (active) logger.step(step) 249 | } 250 | } 251 | } 252 | 253 | function runNonInteractiveMode( 254 | testModulePath: string, 255 | requestedTestName: string | null, 256 | config: ResolvedConfig 257 | ) { 258 | console.log(chalk.bold.yellow('## Generating test plan...')) 259 | const tests = singleton 260 | .loadTests( 261 | () => { 262 | require(testModulePath) 263 | }, 264 | { logger: createFilteredLogger(requestedTestName) } 265 | ) 266 | .filter(filterTest(requestedTestName)) 267 | if (!tests.length) { 268 | throw new Error('No tests found.') 269 | } 270 | if (tests.length > 1) { 271 | console.log() 272 | console.log(chalk.bold.red('Multiple tests found.')) 273 | console.log(' You must specify a test to run.') 274 | console.log(' Use `--list` to see a list of tests.') 275 | process.exitCode = 3 276 | return 277 | } 278 | console.log( 279 | chalk.dim('* ') + chalk.green('Test plan generated successfully.') 280 | ) 281 | console.log() 282 | 283 | console.log(chalk.bold.yellow('## Running tests...')) 284 | runTest().catch(e => 285 | setTimeout(() => { 286 | throw e 287 | }) 288 | ) 289 | 290 | async function runTest() { 291 | const reporter = createReporter( 292 | testModulePath, 293 | tests[0].name, 294 | config.createTestReporter 295 | ) 296 | const iterationListener: IIterationListener = { 297 | onEnter: node => reporter.onEnterStep(node), 298 | onExit: (node, error) => reporter.onExitStep(node, error) 299 | } 300 | const tester = createTestIterator(createLogVisitor(), iterationListener) 301 | const errors: Error[] = [] 302 | const started = Date.now() 303 | tester.setTest(tests[0]) 304 | tester.begin() 305 | while (!tester.isDone()) { 306 | await runNext(tester, config, state, e => errors.push(e)) 307 | } 308 | reporter.onFinish(errors) 309 | const timeTaken = Date.now() - started 310 | const formattedTimeTaken = chalk.dim(ms(timeTaken)) 311 | if (errors.length) { 312 | if (errors.every(e => isPendingError(e))) { 313 | console.log(chalk.bold.yellow('Test pending'), formattedTimeTaken) 314 | process.exitCode = 2 315 | } else { 316 | console.log(chalk.bold.red('Test failed'), formattedTimeTaken) 317 | process.exitCode = 1 318 | } 319 | } else { 320 | console.log(chalk.bold.green('すばらしい!'), formattedTimeTaken) 321 | } 322 | } 323 | } 324 | 325 | function createLogVisitor() { 326 | return { 327 | visitNode(node) { 328 | if (node.children) { 329 | console.log(chalk.dim('Step'), prettyFormatStep(node)) 330 | } 331 | }, 332 | visitDeferNode(node) { 333 | console.log( 334 | chalk.dim('Step'), 335 | prettyFormatStep(node), 336 | chalk.bold.magenta('DEFER') 337 | ) 338 | } 339 | } 340 | } 341 | 342 | async function runNext( 343 | tester: ITestIterator, 344 | config: ResolvedConfig, 345 | state, 346 | onError: (e: Error) => void 347 | ) { 348 | const step = tester.getCurrentStep() 349 | const indent = 7 + (step.number || '').length 350 | 351 | process.stdout.write( 352 | chalk.dim((step.defer ? 'Deferred ' : '') + 'Step ') + 353 | indentString(prettyFormatStep(step), indent).substr(indent) + 354 | '...' 355 | ) 356 | const started = Date.now() 357 | const formatTimeTaken = () => chalk.dim(ms(Date.now() - started)) 358 | const log: { text: string; timestamp: number }[] = [] 359 | const context: ITestExecutionContext = { 360 | log: (format, ...args) => { 361 | log.push({ 362 | text: util.format(format, ...args), 363 | timestamp: Date.now() 364 | }) 365 | }, 366 | attach: (name, buffer, mimeType) => { 367 | const buf = Buffer.from(buffer) 368 | singletonAllureInstance.currentReportingInterface.addAttachment( 369 | name, 370 | buf, 371 | mimeType 372 | ) 373 | context.log( 374 | 'Attachment added: "%s" (%s, %s bytes)', 375 | name, 376 | mimeType, 377 | buf.length 378 | ) 379 | } 380 | } 381 | currentActionContext.current = { state, context } 382 | try { 383 | if (!step || !step.action) { 384 | throw new Error('Internal error: No step to run.') 385 | } 386 | const action = step.action 387 | const promise = config.wrapAction( 388 | step, 389 | async () => action(state, context), 390 | state, 391 | context 392 | ) 393 | if ( 394 | (promise && typeof promise.then !== 'function') || 395 | (!promise && promise !== undefined) 396 | ) { 397 | throw new Error( 398 | 'An action should return a Promise (async) or undefined (sync).' 399 | ) 400 | } 401 | await Promise.resolve(promise) 402 | console.log('\b\b\b', chalk.bold.green('OK'), formatTimeTaken()) 403 | flushLog() 404 | tester.actionPassed() 405 | } catch (e) { 406 | const definition = 407 | 'Hint: Here is where this action has been defined:\n at ' + 408 | step.actionDefinition 409 | if (isPendingError(e)) { 410 | console.log('\b\b\b', chalk.bold.cyan('PENDING'), formatTimeTaken()) 411 | console.log( 412 | chalk.cyan( 413 | indentString( 414 | 'Aborting test because it is pending.\n' + definition, 415 | indent 416 | ) 417 | ) 418 | ) 419 | } else { 420 | console.log('\b\b\b', chalk.bold.red('NG'), formatTimeTaken()) 421 | console.log(chalk.red(indentString(e.stack + '\n' + definition, indent))) 422 | } 423 | flushLog() 424 | onError(e) 425 | tester.actionFailed(e) 426 | } finally { 427 | currentActionContext.current = null 428 | } 429 | 430 | function flushLog() { 431 | for (const item of log) { 432 | const logText = 433 | chalk.dim('* ') + chalk.cyan(indentString(item.text, 2).substr(2)) 434 | console.log(indentString(logText, indent)) 435 | } 436 | if (log.length > 0) { 437 | const logText = log 438 | .map(item => { 439 | const prefix = `[${new Date(item.timestamp).toJSON()}] ` 440 | return ( 441 | prefix + 442 | indentString(item.text, prefix.length).substr(prefix.length) 443 | ) 444 | }) 445 | .join('\n') 446 | singletonAllureInstance.currentReportingInterface.addAttachment( 447 | 'Action log', 448 | Buffer.from(logText, 'utf8'), 449 | 'text/plain' 450 | ) 451 | } 452 | } 453 | } 454 | 455 | function filterTest(requestedTestName: string | null) { 456 | return (root: IStep) => { 457 | if (!requestedTestName) return true 458 | return String(root.name) === requestedTestName 459 | } 460 | } 461 | 462 | export default main 463 | -------------------------------------------------------------------------------- /src/configuration.ts: -------------------------------------------------------------------------------- 1 | import { IConfig, ActionWrapper } from './types' 2 | 3 | export interface ResolvedConfig extends IConfig { 4 | wrapAction: ActionWrapper 5 | } 6 | 7 | export function resolveConfig(config: IConfig): ResolvedConfig { 8 | return { 9 | wrapAction: config.wrapAction || ((step, execute) => execute()), 10 | createTestReporter: config.createTestReporter 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/createReporter.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { createHash } from 'crypto' 3 | import singletonAllureInstance from './singletonAllureInstance' 4 | import { 5 | AllureRuntime, 6 | AllureConfig, 7 | ExecutableItemWrapper, 8 | AllureTest, 9 | AllureStep, 10 | Stage, 11 | Status, 12 | LabelName, 13 | AllureGroup, 14 | FileSystemAllureWriter 15 | } from 'allure-js-commons' 16 | import { hostname } from 'os' 17 | import { StepName } from './StepName' 18 | import { IStep, ITestReporter, IConfig } from './types' 19 | import { isPendingError } from './PendingError' 20 | 21 | class CompositeTestReporter implements ITestReporter { 22 | constructor(public reporters: ITestReporter[]) {} 23 | onFinish(errors: Error[]) { 24 | this.reporters.forEach(reporter => reporter.onFinish(errors)) 25 | } 26 | onEnterStep(step: IStep) { 27 | this.reporters.forEach(reporter => reporter.onEnterStep(step)) 28 | } 29 | onExitStep(step: IStep, error?: Error) { 30 | this.reporters.forEach(reporter => reporter.onExitStep(step, error)) 31 | } 32 | } 33 | 34 | class AllureTestReporter implements ITestReporter { 35 | private _stack: IStepStack 36 | private _group: AllureGroup 37 | 38 | constructor({ 39 | suiteName, 40 | caseName, 41 | resultsDir 42 | }: { 43 | suiteName: string 44 | caseName: string 45 | resultsDir: string 46 | }) { 47 | const historyId = createHash('md5') 48 | .update([suiteName, caseName].join(' / ')) 49 | .digest('hex') 50 | 51 | const allureConfig: AllureConfig = { resultsDir } 52 | const writer = new FileSystemAllureWriter(allureConfig) 53 | const runtime = new AllureRuntime({ ...allureConfig, writer }) 54 | const group = runtime.startGroup(suiteName) 55 | const test = group.startTest(caseName) 56 | const prescriptVersion = require('../package').version 57 | test.historyId = historyId 58 | test.addLabel(LabelName.THREAD, `${process.pid}`) 59 | test.addLabel(LabelName.HOST, `${hostname()}`) 60 | test.addLabel(LabelName.FRAMEWORK, `prescript@${prescriptVersion}`) 61 | for (const [key, value] of Object.entries(process.env)) { 62 | if (key.startsWith('ALLURE_ENV_') && value) { 63 | test.addParameter(key, value) 64 | } 65 | } 66 | 67 | this._stack = new TestStepStack(test) 68 | this._group = group 69 | singletonAllureInstance.currentReportingInterface = { 70 | addAttachment: (name, buf, mimeType) => { 71 | const sha = createHash('sha256') 72 | .update(buf) 73 | .digest('hex') 74 | const fileName = sha + path.extname(name) 75 | writer.writeAttachment(fileName, buf) 76 | this._stack 77 | .getExecutableItem() 78 | .addAttachment(name, mimeType as any, fileName) 79 | } 80 | } 81 | } 82 | 83 | onEnterStep(node: IStep) { 84 | if (!node.number) { 85 | return 86 | } 87 | this._stack = this._stack.push(String(node.name)) 88 | } 89 | 90 | onExitStep(node: IStep, error?: Error) { 91 | if (!node.number) { 92 | return 93 | } 94 | this._stack = this._stack.pop(error) 95 | } 96 | 97 | onFinish(errors: Error[]) { 98 | this._stack = this._stack.pop(errors[0]) 99 | this._group.endGroup() 100 | } 101 | } 102 | 103 | export default function createReporter( 104 | testModulePath: string, 105 | rootStepName: StepName, 106 | customTestReporterFactory: IConfig['createTestReporter'] 107 | ): ITestReporter { 108 | const reporters: ITestReporter[] = [] 109 | 110 | if ( 111 | process.env.ALLURE_SUITE_NAME || 112 | process.env.ALLURE_RESULTS_DIR || 113 | process.env.ALLURE_CASE_NAME 114 | ) { 115 | const suiteName = process.env.ALLURE_SUITE_NAME || 'prescript' 116 | const getDefaultCaseName = () => { 117 | const testPath = path.relative(process.cwd(), testModulePath) 118 | const rawTestName = String(rootStepName) 119 | const testName = rawTestName === '[implicit test]' ? '' : rawTestName 120 | return `${testPath}${testName ? ` - ${testName}` : ''}` 121 | } 122 | const caseName = process.env.ALLURE_CASE_NAME || getDefaultCaseName() 123 | const resultsDir = process.env.ALLURE_RESULTS_DIR || 'allure-results' 124 | reporters.push(new AllureTestReporter({ suiteName, caseName, resultsDir })) 125 | } 126 | 127 | if (customTestReporterFactory) { 128 | reporters.push( 129 | customTestReporterFactory(testModulePath, String(rootStepName)) 130 | ) 131 | } 132 | 133 | return new CompositeTestReporter(reporters) 134 | } 135 | 136 | type Outcome = Error | undefined 137 | 138 | interface IStepStack { 139 | push: (stepName: string) => IStepStack 140 | pop: (outcome: Outcome) => IStepStack 141 | getExecutableItem: () => ExecutableItemWrapper 142 | } 143 | 144 | const saveOutcome = ( 145 | executableItem: ExecutableItemWrapper, 146 | outcome: Outcome 147 | ) => { 148 | if (!outcome) { 149 | executableItem.status = Status.PASSED 150 | executableItem.stage = Stage.FINISHED 151 | return 152 | } 153 | if (isPendingError(outcome)) { 154 | executableItem.stage = Stage.FINISHED 155 | executableItem.status = Status.SKIPPED 156 | return 157 | } 158 | executableItem.stage = Stage.FINISHED 159 | executableItem.status = Status.FAILED 160 | executableItem.detailsMessage = outcome.message || '' 161 | executableItem.detailsTrace = outcome.stack || '' 162 | } 163 | 164 | class NullStepStack implements IStepStack { 165 | push(): never { 166 | throw new Error('This should not happen: Allure stack is corrupted.') 167 | } 168 | pop(): never { 169 | throw new Error('This should not happen: Allure stack is corrupted.') 170 | } 171 | getExecutableItem(): never { 172 | throw new Error('This should not happen: Allure stack is corrupted.') 173 | } 174 | } 175 | 176 | class TestStepStack implements IStepStack { 177 | constructor(private test: AllureTest) {} 178 | push(stepName: string) { 179 | return new StepStepStack(this, this.test.startStep(stepName)) 180 | } 181 | pop(outcome: Outcome) { 182 | saveOutcome(this.test, outcome) 183 | this.test.endTest() 184 | return new NullStepStack() 185 | } 186 | getExecutableItem() { 187 | return this.test 188 | } 189 | } 190 | 191 | class StepStepStack implements IStepStack { 192 | constructor(private parent: IStepStack, private step: AllureStep) {} 193 | push(stepName: string) { 194 | return new StepStepStack(this, this.step.startStep(stepName)) 195 | } 196 | pop(outcome: Outcome) { 197 | saveOutcome(this.step, outcome) 198 | this.step.endStep() 199 | return this.parent 200 | } 201 | getExecutableItem() { 202 | return this.step 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/createTestIterator.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import loadTestModule from './loadTestModule' 3 | import createTestIterator from './createTestIterator' 4 | 5 | function load(testModule) { 6 | return loadTestModule(testModule, { logger: null })[0] 7 | } 8 | 9 | describe('a test iterator', () => { 10 | describe('a simple test', () => { 11 | const test = load(({ step, action }) => { 12 | step('Turn on the computer', () => { 13 | step('Plug the computer in', () => { 14 | action(() => {}) 15 | }) 16 | step('Press the power button', () => { 17 | action(() => {}) 18 | }) 19 | }) 20 | step('Write tests', () => { 21 | action(() => {}) 22 | }) 23 | }) 24 | it('maintains the test state', () => { 25 | const tester = createTestIterator() 26 | tester.setTest(test) 27 | expect(tester.getCurrentStepNumber()).toBe(null) 28 | tester.begin() 29 | expect(tester.getCurrentStepNumber()).toBe('1.1') 30 | tester.actionPassed() 31 | expect(tester.getCurrentStepNumber()).toBe('1.2') 32 | tester.actionPassed() 33 | expect(tester.getCurrentStepNumber()).toBe('2') 34 | tester.actionPassed() 35 | expect(tester.getCurrentStepNumber()).toBe(null) 36 | }) 37 | it('allows starting test at arbitrary point', () => { 38 | const tester = createTestIterator() 39 | tester.setTest(test) 40 | expect(tester.getCurrentStepNumber()).toBe(null) 41 | tester.begin('2') 42 | expect(tester.getCurrentStepNumber()).toBe('2') 43 | tester.begin('1.2') 44 | expect(tester.getCurrentStepNumber()).toBe('1.2') 45 | tester.begin('1') 46 | expect(tester.getCurrentStepNumber()).toBe('1.1') 47 | }) 48 | it('becomes already done if trying to start at an invalid point', () => { 49 | const tester = createTestIterator() 50 | tester.setTest(test) 51 | tester.begin('9') 52 | expect(tester.isDone()).toBe(true) 53 | }) 54 | it('skips other steps if test failed', () => { 55 | const tester = createTestIterator() 56 | tester.setTest(test) 57 | tester.begin() 58 | expect(tester.getCurrentStepNumber()).toBe('1.1') 59 | tester.actionFailed(new Error('!!!')) 60 | expect(tester.getCurrentStepNumber()).toBe(null) 61 | }) 62 | }) 63 | 64 | describe('a test with cleanup step', () => { 65 | const test = load(({ step, action, cleanup }) => { 66 | step('Open browser', () => { 67 | action(() => {}) 68 | }) 69 | step('Do something', () => { 70 | action(() => {}) 71 | }) 72 | step('Do something else', () => { 73 | step('Create file', () => { 74 | action(() => {}) 75 | }) 76 | cleanup('Delete file', () => { 77 | action(() => {}) 78 | }) 79 | }) 80 | step('Do something more', () => { 81 | action(() => {}) 82 | }) 83 | cleanup('Close browser', () => { 84 | action(() => {}) 85 | }) 86 | }) 87 | it('should run the cleanup step in normal run', () => { 88 | const tester = createTestIterator() 89 | tester.setTest(test) 90 | tester.begin() 91 | expect(tester.getCurrentStepNumber()).toBe('1') 92 | tester.actionPassed() 93 | expect(tester.getCurrentStepNumber()).toBe('2') 94 | tester.actionPassed() 95 | expect(tester.getCurrentStepNumber()).toBe('3.1') 96 | tester.actionPassed() 97 | expect(tester.getCurrentStepNumber()).toBe('3.2') 98 | tester.actionPassed() 99 | expect(tester.getCurrentStepNumber()).toBe('4') 100 | tester.actionPassed() 101 | expect(tester.getCurrentStepNumber()).toBe('5') 102 | tester.actionPassed() 103 | expect(tester.getCurrentStepNumber()).toBe(null) 104 | }) 105 | it('should run the cleanup step after failure', () => { 106 | const tester = createTestIterator() 107 | tester.setTest(test) 108 | tester.begin() // 1 109 | tester.actionPassed() // 2 110 | tester.actionPassed() // 3.1 111 | tester.actionFailed(new Error('!!!')) // 3.2 (cleanup) 112 | tester.actionPassed() // 5 113 | expect(tester.getCurrentStepNumber()).toBe('5') 114 | tester.actionPassed() 115 | expect(tester.getCurrentStepNumber()).toBe(null) 116 | }) 117 | it('should skip cleanup step in skipped child steps', () => { 118 | const tester = createTestIterator() 119 | tester.setTest(test) 120 | tester.begin() // 1 121 | tester.actionFailed(new Error('!!!')) // 5 122 | expect(tester.getCurrentStepNumber()).toBe('5') 123 | tester.actionPassed() 124 | expect(tester.getCurrentStepNumber()).toBe(null) 125 | }) 126 | }) 127 | 128 | describe('a test with independent step', () => { 129 | const test = load(({ to, defer, action, independent }) => { 130 | action`Arrange`(async () => {}) 131 | defer`Teardown`(async () => {}) 132 | action`Act`(async () => {}) 133 | to`Asserts`(() => { 134 | independent(() => { 135 | action`Assert 1`(async () => {}) 136 | action`Assert 2`(async () => {}) 137 | to`Child asserts`(() => { 138 | action`Assert 3`(async () => {}) 139 | action`Assert 4`(async () => {}) 140 | }) 141 | to`Independent child asserts`(() => { 142 | independent(() => { 143 | action`Assert 5`(async () => {}) 144 | action`Assert 6`(async () => {}) 145 | }) 146 | }) 147 | }) 148 | }) 149 | action`Follow up`(async () => {}) 150 | }) 151 | it('should run all assertions normally', () => { 152 | const sequence = getStepSequence(test) 153 | expect(sequence).toEqual([ 154 | 'Arrange', 155 | 'Act', 156 | 'Assert 1', 157 | 'Assert 2', 158 | 'Assert 3', 159 | 'Assert 4', 160 | 'Assert 5', 161 | 'Assert 6', 162 | 'Follow up', 163 | 'Teardown' 164 | ]) 165 | }) 166 | it('should keep on running independent assertions', () => { 167 | const sequence = getStepSequence(test, { failingSteps: ['Assert 1'] }) 168 | expect(sequence).toEqual([ 169 | 'Arrange', 170 | 'Act', 171 | 'Assert 1', 172 | 'Assert 2', 173 | 'Assert 3', 174 | 'Assert 4', 175 | 'Assert 5', 176 | 'Assert 6', 177 | 'Teardown' 178 | ]) 179 | }) 180 | it('should keep independent one level only', () => { 181 | const sequence = getStepSequence(test, { 182 | failingSteps: ['Assert 1', 'Assert 3', 'Assert 5'] 183 | }) 184 | expect(sequence).toEqual([ 185 | 'Arrange', 186 | 'Act', 187 | 'Assert 1', 188 | 'Assert 2', 189 | 'Assert 3', 190 | 'Assert 5', 191 | 'Assert 6', 192 | 'Teardown' 193 | ]) 194 | }) 195 | }) 196 | 197 | describe('replacing test', () => { 198 | it('clears the program counter', () => { 199 | const tester = createTestIterator() 200 | tester.setTest( 201 | load(({ step, action }) => { 202 | step('Step 1', () => { 203 | action(() => {}) 204 | }) 205 | step('Step 2', () => { 206 | action(() => {}) 207 | }) 208 | step('Step 3', () => { 209 | action(() => {}) 210 | }) 211 | }) 212 | ) 213 | tester.begin() // 1 214 | tester.actionPassed() // 2 215 | expect(tester.getCurrentStepNumber()).toBe('2') 216 | tester.setTest( 217 | load(({ step, action }) => { 218 | step('Step X', () => { 219 | action(() => {}) 220 | }) 221 | step('Step Y', () => { 222 | action(() => {}) 223 | }) 224 | step('Step Z', () => { 225 | action(() => {}) 226 | }) 227 | }) 228 | ) 229 | expect(tester.getCurrentStepNumber()).toBe(null) 230 | }) 231 | }) 232 | }) 233 | 234 | function getStepSequence(test, { failingSteps = [] as string[] } = {}) { 235 | const tester = createTestIterator() 236 | tester.setTest(test) 237 | tester.begin() 238 | const out: string[] = [] 239 | const fail = new Set(failingSteps) 240 | for (;;) { 241 | const step = tester.getCurrentStepNumber() 242 | if (step === null) break 243 | const name = tester.getCurrentStep().name.toString() 244 | out.push(name) 245 | if (fail.has(name)) { 246 | tester.actionFailed(new Error(`Fail on ${name}`)) 247 | } else { 248 | tester.actionPassed() 249 | } 250 | } 251 | return out 252 | } 253 | -------------------------------------------------------------------------------- /src/createTestIterator.ts: -------------------------------------------------------------------------------- 1 | import { IIterationListener, IVisitor, IStep, ITestIterator } from './types' 2 | import * as StepName from './StepName' 3 | 4 | export default function createTestIterator( 5 | visitor?, 6 | iterationListener? 7 | ): ITestIterator { 8 | let test: IStep = { children: [], name: StepName.coerce('(not loaded)') } 9 | let stepper 10 | 11 | return { 12 | setTest(_test) { 13 | test = _test 14 | stepper = null 15 | }, 16 | getTest() { 17 | return test 18 | }, 19 | begin(beginningStep?) { 20 | stepper = createStepper(test, beginningStep, visitor, iterationListener) 21 | }, 22 | getCurrentStepNumber() { 23 | if (!stepper) return null 24 | if (stepper.isDone()) return null 25 | return stepper.getCurrentStep().number 26 | }, 27 | getCurrentStep() { 28 | if (!stepper) throw new Error('Test not started.') 29 | if (stepper.isDone()) throw new Error('Test already finished.') 30 | return stepper.getCurrentStep() 31 | }, 32 | isDone() { 33 | if (!stepper) return false 34 | return stepper.isDone() 35 | }, 36 | actionPassed() { 37 | stepper.actionPassed() 38 | }, 39 | actionFailed(error) { 40 | stepper.actionFailed(error) 41 | } 42 | } 43 | } 44 | 45 | function createStepper( 46 | test, 47 | beginningStep?, 48 | visitor?: Partial, 49 | iterationListener: Partial = {} 50 | ) { 51 | function* generateSteps() { 52 | let found = !beginningStep 53 | const deferredSteps: IStep[] = [] 54 | yield* walk(test) 55 | for (const step of deferredSteps) { 56 | yield* walk(step) 57 | } 58 | function* walk(node) { 59 | if (!found && node.number === beginningStep) { 60 | found = true 61 | } 62 | if (found && visitor && visitor.visitNode && node.number) { 63 | visitor.visitNode(node) 64 | } 65 | if (iterationListener.onEnter) { 66 | iterationListener.onEnter(node) 67 | } 68 | if (node.action) { 69 | if (found) { 70 | const { ok, error } = yield node 71 | if (iterationListener.onExit) { 72 | iterationListener.onExit(node, error) 73 | } 74 | return ok 75 | } else { 76 | return true 77 | } 78 | } else if (node.children) { 79 | let stillOk = true 80 | for (const child of node.children) { 81 | if (child.defer && stillOk) { 82 | if (visitor && visitor.visitDeferNode && child.number) { 83 | visitor.visitDeferNode(child) 84 | } 85 | deferredSteps.push(child) 86 | } else if (child.cleanup || child.independent || stillOk) { 87 | stillOk = (yield* walk(child)) && stillOk 88 | } 89 | } 90 | if (iterationListener.onExit) { 91 | iterationListener.onExit(node) 92 | } 93 | return stillOk 94 | } 95 | } 96 | } 97 | const iterator = generateSteps() 98 | let currentState = iterator.next() 99 | return { 100 | isDone() { 101 | return currentState.done 102 | }, 103 | getCurrentStep() { 104 | return currentState.value 105 | }, 106 | actionPassed() { 107 | currentState = iterator.next({ ok: true }) 108 | }, 109 | actionFailed(error) { 110 | currentState = iterator.next({ ok: false, error }) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/createUI.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import util from 'util' 3 | import prettyFormatStep from './prettyFormatStep' 4 | 5 | export default function createUI() { 6 | const ui = { 7 | testLoadStarted() { 8 | console.log( 9 | chalk.bold.yellow('## Loading test and generating test plan...') 10 | ) 11 | }, 12 | testLoadCompleted(test) { 13 | console.log( 14 | chalk.dim('* ') + chalk.green('Test plan generated successfully!') 15 | ) 16 | console.log() 17 | }, 18 | testLoadError(e: Error) { 19 | console.log(chalk.bold.red('Cannot load the test file.')) 20 | console.log(chalk.red(e.stack || 'Unknown stack trace...')) 21 | console.log() 22 | }, 23 | 24 | moduleUncacheStarted() { 25 | console.log(chalk.bold.yellow('## Clearing Node module cache...')) 26 | }, 27 | moduleUncached(key: string) { 28 | console.log(chalk.dim('*'), 'Reloading', chalk.cyan(key)) 29 | }, 30 | moduleUncacheCompleted() { 31 | console.log() 32 | }, 33 | 34 | onUserInterrupted() { 35 | console.log(chalk.yellow('Interrupted by user.')) 36 | }, 37 | 38 | developmentModeStarted(environment) { 39 | startInteractiveMode(environment) 40 | } 41 | } 42 | 43 | function startInteractiveMode(environment) { 44 | console.log(chalk.bold.yellow('## Entering development mode...')) 45 | console.log('Welcome to prescript development mode.') 46 | console.log() 47 | announceStatus() 48 | hint('help', 'for more information') 49 | console.log() 50 | 51 | const vorpal = require('vorpal')() 52 | 53 | vorpal 54 | .command('inspect') 55 | .alias('i') 56 | .description('Inspect the test state') 57 | .action(function(args, callback) { 58 | console.log('This is current test state:') 59 | console.log(util.inspect(environment.getState())) 60 | console.log() 61 | callback() 62 | }) 63 | 64 | vorpal 65 | .command('status') 66 | .alias('s') 67 | .description('Show the test status') 68 | .action(function(args, callback) { 69 | console.log('This is the test plan with current test status:') 70 | const currentStepNumber = environment.getCurrentStepNumber() 71 | const previousResult = environment.getPreviousResult() 72 | let printed = false 73 | environment.forEachStep(step => { 74 | const prefix = 75 | step.number === currentStepNumber 76 | ? chalk.bold.blue('次は') 77 | : previousResult && step.number === previousResult.stepNumber 78 | ? previousResult.error 79 | ? chalk.bold.bgRed(' NG ') 80 | : chalk.bold.bgGreen(' OK ') 81 | : ' ' 82 | printed = true 83 | console.log(prefix, prettyFormatStep(step)) 84 | }) 85 | if (!printed) { 86 | console.log(chalk.yellow('The test plan is empty.')) 87 | } 88 | console.log() 89 | announceStatus() 90 | console.log() 91 | callback() 92 | }) 93 | 94 | vorpal 95 | .command('reload') 96 | .alias('r') 97 | .description('Reload the test file') 98 | .action(function(args, callback) { 99 | const reloadResult = environment.reload() 100 | console.log('Test file is reloaded.') 101 | const jump = reloadResult.jump 102 | if (jump) { 103 | if (jump.success) { 104 | console.log( 105 | 'Jumping to', 106 | prettyFormatStep(environment.getCurrentStep()) 107 | ) 108 | } else { 109 | console.log('Cannot jump to previously run step ' + jump.target) 110 | } 111 | } 112 | console.log() 113 | announceStatus() 114 | console.log() 115 | callback() 116 | }) 117 | 118 | vorpal 119 | .command('continue') 120 | .alias('c') 121 | .description('Continue running the test until there is an error') 122 | .action(function(args, callback) { 123 | handleRun(environment.continue(), callback) 124 | }) 125 | .cancel(() => { 126 | environment.cancel() 127 | }) 128 | 129 | vorpal 130 | .command('next') 131 | .alias('n') 132 | .description('Run the next step.') 133 | .action(function(args, callback) { 134 | handleRun(environment.nextStep(), callback) 135 | }) 136 | 137 | vorpal 138 | .command('jump ') 139 | .alias('j') 140 | .description('Jump to a step number') 141 | .action(function(args, callback) { 142 | environment.jumpTo(String(args.stepNumber)) 143 | announceStatus() 144 | console.log() 145 | callback() 146 | }) 147 | 148 | vorpal 149 | .command('runto ') 150 | .description('Run from current step to step number') 151 | .action(function(args, callback) { 152 | handleRun(environment.runTo(String(args.stepNumber)), callback) 153 | }) 154 | 155 | vorpal.delimiter('prescript>').show() 156 | 157 | function handleRun(promise, callback) { 158 | return promise.then( 159 | () => { 160 | announcePrevious() 161 | announceStatus() 162 | console.log() 163 | callback && callback() 164 | }, 165 | err => { 166 | callback && callback(err) 167 | } 168 | ) 169 | } 170 | 171 | function announcePrevious() { 172 | const previousResult = environment.getPreviousResult() 173 | if (previousResult) { 174 | if (previousResult.error) { 175 | console.log( 176 | chalk.bold.red( 177 | 'Step ' + previousResult.stepNumber + ' encountered an error' 178 | ) 179 | ) 180 | } 181 | } 182 | } 183 | 184 | function announceStatus() { 185 | const previousResult = environment.getPreviousResult() 186 | const current = environment.getCurrentStepNumber() 187 | if (previousResult && previousResult.error) { 188 | hint('reload', 'after fixing the test to reload the test file') 189 | } 190 | if (current) { 191 | console.log( 192 | chalk.bold.blue('次は'), 193 | prettyFormatStep(environment.getCurrentStep()) 194 | ) 195 | hint('next', 'to run this step only') 196 | hint('continue', 'to run from this step until next error') 197 | hint('runto', 'to run into specific step') 198 | } else { 199 | console.log(chalk.yellow('Nothing to be run.')) 200 | hint('status', 'to see the test plan') 201 | hint('jump', 'to jump to a step number') 202 | } 203 | } 204 | } 205 | 206 | function hint(commandName, description) { 207 | console.log('Type', chalk.cyan(commandName), description) 208 | } 209 | 210 | return ui 211 | } 212 | -------------------------------------------------------------------------------- /src/currentActionContext.ts: -------------------------------------------------------------------------------- 1 | import { ITestExecutionContext } from './types' 2 | 3 | export default { 4 | current: null as { 5 | state: Prescript.GlobalState 6 | context: ITestExecutionContext 7 | } | null 8 | } 9 | -------------------------------------------------------------------------------- /src/globalState.ts: -------------------------------------------------------------------------------- 1 | export const state: Prescript.GlobalState = {} 2 | 3 | Object.assign(global, { 4 | prescriptState: state 5 | }) 6 | -------------------------------------------------------------------------------- /src/isStepExist.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import loadTestModule from './loadTestModule' 3 | import isStepExist from './isStepExist' 4 | 5 | function load(testModule) { 6 | return loadTestModule(testModule, { logger: null }) 7 | } 8 | 9 | describe('isStepExist', () => { 10 | const tests = load(({ step, action }) => { 11 | step('Step 1', () => { 12 | step('step 1.1', () => { 13 | action(() => {}) 14 | }) 15 | }) 16 | step('step 2', () => { 17 | step('step 2.1', () => { 18 | action(() => {}) 19 | }) 20 | }) 21 | }) 22 | it('should be able to determined if steps exists', () => { 23 | expect(isStepExist(tests[0], '1.1')).toEqual(true) 24 | }) 25 | it('should be able to determined if steps not exists', () => { 26 | expect(isStepExist(tests[0], '1.2')).toEqual(false) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/isStepExist.ts: -------------------------------------------------------------------------------- 1 | import walkSteps from './walkSteps' 2 | 3 | export default function isStepExist(root, number) { 4 | let isExists = false 5 | walkSteps(root, node => { 6 | if (node.number === number) isExists = true 7 | }) 8 | return isExists 9 | } 10 | -------------------------------------------------------------------------------- /src/loadTestModule.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import loadTestModule from './loadTestModule' 3 | import walkSteps from './walkSteps' 4 | import { IStep } from './types' 5 | 6 | function load(testModule) { 7 | return loadTestModule(testModule, { logger: null }) 8 | } 9 | 10 | describe('a test module', () => { 11 | it('can be empty', () => { 12 | const tests = load(({ step, test }) => { 13 | test('Meow', () => {}) 14 | }) 15 | expect(tests[0].children!.length).toBe(0) 16 | }) 17 | 18 | it('can have steps and actions', () => { 19 | const tests = load(({ step, action }) => { 20 | step('Turn on the computer', () => { 21 | step('Plug the computer in', () => { 22 | action(() => {}) 23 | }) 24 | step('Press the power button', () => { 25 | action(() => {}) 26 | }) 27 | }) 28 | step('Write tests', () => { 29 | action(() => {}) 30 | }) 31 | }) 32 | expect(numberedDescriptionsOf(tests[0])).toEqual([ 33 | '1. Turn on the computer', 34 | '1.1. Plug the computer in', 35 | '1.2. Press the power button', 36 | '2. Write tests' 37 | ]) 38 | }) 39 | 40 | it('cannot have an empty step', () => { 41 | expect(() => { 42 | load(({ step }) => { 43 | step('Step A', () => {}) 44 | }) 45 | }).toThrowError(/empty step/) 46 | }) 47 | 48 | it('cannot have multiple tests with same name', () => { 49 | expect(() => { 50 | load(({ test, action }) => { 51 | test('A', () => { 52 | action('wow', () => {}) 53 | }) 54 | test('A', () => { 55 | action('wow', () => {}) 56 | }) 57 | }) 58 | }).toThrowError(/Test name must be unique/) 59 | }) 60 | }) 61 | 62 | function numberedDescriptionsOf(root: IStep) { 63 | const out: string[] = [] 64 | walkSteps(root, step => { 65 | out.push(`${step.number}. ${step.name}`) 66 | }) 67 | return out 68 | } 69 | -------------------------------------------------------------------------------- /src/loadTestModule.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import chalk from 'chalk' 3 | import * as StepName from './StepName' 4 | import ErrorStackParser from 'error-stack-parser' 5 | import prettyFormatStep from './prettyFormatStep' 6 | import { 7 | IStep, 8 | ActionFunction, 9 | StepDefName, 10 | ITestLoadLogger, 11 | IPrescriptAPI 12 | } from './types' 13 | import { PendingError } from './PendingError' 14 | 15 | type StackTrace = ErrorStackParser.StackFrame[] 16 | 17 | export interface ITestLoadOptions { 18 | logger?: ITestLoadLogger | null 19 | } 20 | 21 | function loadTest( 22 | testModule: (context: IPrescriptAPI) => void, 23 | options: ITestLoadOptions = {} 24 | ): IStep[] { 25 | const { logger: inLogger = createConsoleLogger() } = options 26 | 27 | const implicitRoot: IStep = { 28 | name: StepName.named`[implicit test]`, 29 | children: [] 30 | } 31 | 32 | const tests: IStep[] = [] 33 | const usedTestNames = {} 34 | 35 | let currentStep: IStep | null 36 | let currentTest: ITest | null 37 | 38 | const logger: ITestLoadLogger = inLogger || createNullLogger() 39 | const independentContextSet = new Set() 40 | 41 | function appendStep( 42 | options: { 43 | name: StepName.StepName 44 | creator?: string 45 | definition: string 46 | cleanup?: boolean 47 | defer?: boolean 48 | pending?: boolean 49 | }, 50 | f: () => X 51 | ): X { 52 | const { name, creator, definition, cleanup, defer, pending } = options 53 | if (!currentStep) { 54 | throw new Error( 55 | 'Invalid state... This should not happen! currentStep is null.' 56 | ) 57 | } 58 | if (currentStep.action) { 59 | throw new Error( 60 | 'A step may only have an action or sub-steps but not both.' 61 | ) 62 | } 63 | const parentStep = currentStep 64 | if (!parentStep.children) { 65 | parentStep.children = [] 66 | } 67 | const independent = independentContextSet.has(parentStep) 68 | const number = 69 | (parentStep.number ? parentStep.number + '.' : '') + 70 | (parentStep.children.length + 1) 71 | const childStep: IStep = { 72 | name, 73 | independent, 74 | creator, 75 | definition, 76 | cleanup, 77 | number, 78 | pending, 79 | defer 80 | } 81 | parentStep.children.push(childStep) 82 | logger.step(childStep) 83 | try { 84 | currentStep = childStep 85 | const result = f() 86 | if (!childStep.children && !childStep.action) { 87 | throw new Error( 88 | 'Unexpected empty step. A step must either have children or an action.' 89 | ) 90 | } 91 | return result 92 | } finally { 93 | currentStep = parentStep 94 | } 95 | } 96 | 97 | function setAction(f: ActionFunction, definition: string) { 98 | if (!currentStep) { 99 | throw new Error( 100 | 'Invalid state... This should not happen! currentStep is null.' 101 | ) 102 | } 103 | if (currentStep.action) { 104 | throw new Error('A step may only have one action block.') 105 | } 106 | if (currentStep.children) { 107 | throw new Error( 108 | 'A step may only have an action or sub-steps but not both.' 109 | ) 110 | } 111 | currentStep.action = f 112 | currentStep.actionDefinition = definition 113 | } 114 | 115 | function getSource(stackTrace: StackTrace) { 116 | const stackFrame = stackTrace.filter( 117 | frame => 118 | frame.fileName && 119 | path.relative(__dirname, frame.fileName).startsWith('..') 120 | )[0] 121 | if (!stackFrame) return '(unknown)' 122 | return stackFrame.fileName + ':' + stackFrame.lineNumber 123 | } 124 | 125 | function appendTest(name: StepName.StepName, f: () => X): X { 126 | if (currentTest && currentTest.root === implicitRoot) { 127 | if (implicitRoot.children && implicitRoot.children.length) { 128 | throw new Error('An implicit test has been started.') 129 | } 130 | currentTest = null 131 | currentStep = null 132 | } 133 | if (currentTest) { 134 | throw new Error('test() calls may not be nested.') 135 | } 136 | const nameStr = String(name) 137 | if (usedTestNames[nameStr]) { 138 | throw new Error( 139 | `Test name must be unique. A test named "${nameStr}" has already been declared.` 140 | ) 141 | } 142 | usedTestNames[nameStr] = true 143 | const root = (currentStep = { 144 | name: name, 145 | children: [] 146 | }) 147 | logger.test(name) 148 | currentTest = createTest(root) 149 | tests.push(root) 150 | try { 151 | const value = f() 152 | finishTest() 153 | return value 154 | } finally { 155 | currentStep = null 156 | currentTest = null 157 | } 158 | } 159 | 160 | function ensureDeprecatedAPIUsersDoNotUseTaggedTemplate(strings: any) { 161 | if (Array.isArray(strings)) { 162 | throw new Error( 163 | 'The old deprecated API does not support tagged template literal syntax.' 164 | ) 165 | } 166 | } 167 | 168 | const DEFAULT_COMPOSITE_STEP = () => { 169 | context.pending() 170 | } 171 | const DEFAULT_ACTION_STEP = async () => { 172 | throw new PendingError() 173 | } 174 | 175 | const context: IPrescriptAPI = { 176 | step(inName: StepDefName, f: () => X): X { 177 | ensureDeprecatedAPIUsersDoNotUseTaggedTemplate(inName) 178 | const name = StepName.coerce(inName) 179 | const definition = getSource( 180 | ErrorStackParser.parse(new Error(`Step: ${name}`)) 181 | ) 182 | return appendStep({ name, definition }, f) 183 | }, 184 | test(...args: any[]): any { 185 | if (isTemplateString(args[0])) { 186 | const name = StepName.named(args[0], ...args.slice(1)) 187 | return (f: () => X) => appendTest(name, f) 188 | } else { 189 | const name = StepName.coerce(args[0]) 190 | return appendTest(name, args[1]) 191 | } 192 | }, 193 | cleanup(inName: StepDefName, f: () => X): X { 194 | ensureDeprecatedAPIUsersDoNotUseTaggedTemplate(inName) 195 | const name = StepName.coerce(inName) 196 | const definition = getSource( 197 | ErrorStackParser.parse(new Error(`Cleanup step: ${name}`)) 198 | ) 199 | return appendStep({ name, definition, cleanup: true }, f) 200 | }, 201 | onFinish(f: () => void): void { 202 | if (!currentStep) { 203 | throw new Error( 204 | 'Invalid state... This should not happen! currentStep is null.' 205 | ) 206 | } 207 | if (!currentTest) { 208 | throw new Error( 209 | 'Invalid state... This should not happen! currentTest is null.' 210 | ) 211 | } 212 | const definition = getSource( 213 | ErrorStackParser.parse(new Error(`onFinish`)) 214 | ) 215 | const cause = currentStep.number 216 | currentTest.finishActions.push({ action: f, definition, cause }) 217 | }, 218 | use(m: (context: IPrescriptAPI) => X): X { 219 | return m(context) 220 | }, 221 | action(...args: any[]): any { 222 | if (isTemplateString(args[0])) { 223 | const name = StepName.named(args[0], ...args.slice(1)) 224 | const definition = getSource( 225 | ErrorStackParser.parse(new Error(`Action Step: ${name}`)) 226 | ) 227 | return (f: ActionFunction = DEFAULT_ACTION_STEP) => 228 | appendStep({ name, definition }, () => { 229 | return setAction(f, definition) 230 | }) 231 | } else if (!args[1]) { 232 | const definition = getSource( 233 | ErrorStackParser.parse(new Error(`Action definition`)) 234 | ) 235 | const f = args[0] as ActionFunction 236 | return setAction(f, definition) 237 | } else { 238 | const name = StepName.coerce(args[0]) 239 | const f = args[1] as ActionFunction 240 | const definition = getSource( 241 | ErrorStackParser.parse(new Error(`Action Step: ${name}`)) 242 | ) 243 | return appendStep({ name, definition }, () => { 244 | return setAction(f, definition) 245 | }) 246 | } 247 | }, 248 | defer(...args: any[]): any { 249 | if (isTemplateString(args[0])) { 250 | const name = StepName.named(args[0], ...args.slice(1)) 251 | const definition = getSource( 252 | ErrorStackParser.parse(new Error(`Deferred Action Step: ${name}`)) 253 | ) 254 | return (f: ActionFunction = DEFAULT_ACTION_STEP) => 255 | appendStep({ name, definition, defer: true }, () => { 256 | return setAction(f, definition) 257 | }) 258 | } else { 259 | const name = StepName.coerce(args[0]) 260 | const definition = getSource( 261 | ErrorStackParser.parse(new Error(`Deferred Action Step: {$name}`)) 262 | ) 263 | const f = args[1] as ActionFunction 264 | return appendStep({ name, definition, defer: true }, () => { 265 | return setAction(f, definition) 266 | }) 267 | } 268 | }, 269 | to(...args: any[]): any { 270 | if (isTemplateString(args[0])) { 271 | const name = StepName.named(args[0], ...args.slice(1)) 272 | const definition = getSource( 273 | ErrorStackParser.parse(new Error(`Composite Step: ${name}`)) 274 | ) 275 | return (f: () => X = DEFAULT_COMPOSITE_STEP as any) => 276 | appendStep({ name, definition }, f) 277 | } else { 278 | const name = StepName.coerce(args[0]) 279 | const definition = getSource( 280 | ErrorStackParser.parse(new Error(`Composite Step: ${name}`)) 281 | ) 282 | const f = args[1] 283 | return appendStep({ name, definition }, f) 284 | } 285 | }, 286 | pending() { 287 | const error = new PendingError() 288 | const definition = getSource(ErrorStackParser.parse(new Error(`Pending`))) 289 | return appendStep( 290 | { name: StepName.coerce('Pending'), definition, pending: true }, 291 | () => { 292 | context.action(() => { 293 | throw error 294 | }) 295 | } 296 | ) 297 | }, 298 | independent(f) { 299 | if (!currentStep) { 300 | throw new Error( 301 | 'Invalid state... This should not happen! currentStep is null.' 302 | ) 303 | } 304 | independentContextSet.add(currentStep) 305 | try { 306 | return f() 307 | } finally { 308 | independentContextSet.delete(currentStep) 309 | } 310 | } 311 | } 312 | 313 | function finishTest() { 314 | if (!currentTest) { 315 | throw new Error('Invalid state... currentTest is null.') 316 | } 317 | for (const entry of currentTest.finishActions) { 318 | appendStep( 319 | { 320 | name: StepName.coerce('Post-test actions'), 321 | creator: entry.cause, 322 | definition: entry.definition, 323 | cleanup: true 324 | }, 325 | () => { 326 | entry.action() 327 | } 328 | ) 329 | } 330 | } 331 | 332 | function load() { 333 | currentStep = implicitRoot 334 | currentTest = createTest(implicitRoot) 335 | try { 336 | testModule(context) 337 | if (currentTest !== null) { 338 | finishTest() 339 | } 340 | } finally { 341 | currentStep = null 342 | currentTest = null 343 | } 344 | if ( 345 | !tests.length && 346 | implicitRoot.children && 347 | implicitRoot.children.length 348 | ) { 349 | tests.push(implicitRoot) 350 | } 351 | return tests 352 | } 353 | 354 | return load() 355 | } 356 | 357 | export function createConsoleLogger() { 358 | return { 359 | step(step: IStep) { 360 | console.log( 361 | chalk.dim((step.defer ? 'Deferred ' : '') + 'Step'), 362 | prettyFormatStep(step) 363 | ) 364 | }, 365 | test(name: StepName.StepName) { 366 | console.log(chalk.yellow(`### ${StepName.format(name)}`)) 367 | } 368 | } 369 | } 370 | 371 | export function createNullLogger() { 372 | return { 373 | step(step: IStep) {}, 374 | test(name: StepName.StepName) {} 375 | } 376 | } 377 | 378 | interface ITest { 379 | root: IStep 380 | finishActions: { 381 | action: () => void 382 | definition: string 383 | cause?: string 384 | }[] 385 | } 386 | 387 | function createTest(root: IStep): ITest { 388 | return { root, finishActions: [] } 389 | } 390 | 391 | export default loadTest 392 | 393 | function isTemplateString(input: any): input is TemplateStringsArray { 394 | return Array.isArray(input) 395 | } 396 | -------------------------------------------------------------------------------- /src/prettyFormatStep.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import * as StepName from './StepName' 3 | import { IStep } from './types' 4 | 5 | export default function prettyFormatStep(step: IStep) { 6 | let result = '' 7 | const write = (stuff: string) => { 8 | result += stuff 9 | } 10 | const numberParts = (step.number || '').split('.') 11 | const frontNumber = numberParts.slice(0, -1).join('.') 12 | const lastNumber = numberParts[numberParts.length - 1] 13 | const formattedName = StepName.format(step.name) 14 | write(chalk.dim(frontNumber + (frontNumber ? '.' : ''))) 15 | write(chalk.bold(lastNumber + '. ') + formattedName) 16 | if (step.children) { 17 | write(':') 18 | } 19 | if (step.creator) { 20 | write(chalk.dim(' (registered at step ' + step.creator + ')')) 21 | } 22 | if (step.cleanup) { 23 | write(chalk.dim(' (cleanup)')) 24 | } 25 | return result 26 | } 27 | -------------------------------------------------------------------------------- /src/singleton.ts: -------------------------------------------------------------------------------- 1 | import loadTestModule, { ITestLoadOptions } from './loadTestModule' 2 | import { IStep } from './types' 3 | 4 | const key = '__prescriptSingletonInstance(╯°□°)╯︵ ┻━┻' 5 | const _global = global as any 6 | 7 | export function getInstance() { 8 | if (!_global[key]) { 9 | throw new Error('prescript is not running in prescripting phase.') 10 | } 11 | return _global[key] 12 | } 13 | 14 | export function loadTests( 15 | f: Function, 16 | options: ITestLoadOptions = {} 17 | ): IStep[] { 18 | try { 19 | return loadTestModule(context => { 20 | _global[key] = context 21 | f() 22 | }, options) 23 | } finally { 24 | _global[key] = null 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/singletonAllureInstance.ts: -------------------------------------------------------------------------------- 1 | class NullReportingInterface implements IReportingInterface { 2 | addAttachment = () => null 3 | } 4 | 5 | export interface IReportingInterface { 6 | addAttachment: (name: string, buf: Buffer, mimeType: string) => void 7 | } 8 | 9 | export default { 10 | currentReportingInterface: new NullReportingInterface() as IReportingInterface 11 | } 12 | -------------------------------------------------------------------------------- /src/singletonApi.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Acceptance test tool. 3 | * @packageDocumentation 4 | */ 5 | 6 | import currentActionContext from './currentActionContext' 7 | import { isPendingError } from './PendingError' 8 | import { getInstance } from './singleton' 9 | import { ActionFunction, StepDefName } from './types' 10 | 11 | export { isPendingError } from './PendingError' 12 | export { StepName } from './StepName' 13 | export { 14 | ActionFunction, 15 | ActionWrapper, 16 | IConfig, 17 | IStep, 18 | ITestExecutionContext, 19 | ITestReporter, 20 | StepDefName, 21 | Thenable 22 | } from './types' 23 | 24 | /** 25 | * Creates a Test. 26 | * @public 27 | */ 28 | export function test(name: string, f: () => X): X 29 | 30 | /** 31 | * Creates a Test. 32 | * @public 33 | */ 34 | export function test( 35 | nameParts: TemplateStringsArray, 36 | ...substitutions: any[] 37 | ): (f: () => X) => X 38 | 39 | // Implementation 40 | export function test(...args) { 41 | return getInstance().test(...args) 42 | } 43 | 44 | /** 45 | * Creates a Compound Test Step, which can contain child steps. 46 | * @public 47 | */ 48 | export function to(name: string, f: () => X): X 49 | 50 | /** 51 | * Creates a Compound Test Step, which can contain child steps. 52 | * @public 53 | */ 54 | export function to( 55 | nameParts: TemplateStringsArray, 56 | ...substitutions: any[] 57 | ): (f?: () => X) => X 58 | 59 | // Implementation 60 | export function to(...args) { 61 | return getInstance().to(...args) 62 | } 63 | 64 | /** 65 | * Creates an Action Step to be performed at runtime. 66 | * @public 67 | */ 68 | export function action(name: string, f: ActionFunction): void 69 | 70 | /** 71 | * Creates an Action Step to be performed at runtime. 72 | * @public 73 | */ 74 | export function action( 75 | nameParts: TemplateStringsArray, 76 | ...substitutions: any[] 77 | ): (f?: ActionFunction) => void 78 | 79 | /** 80 | * Deprecated: Makes the enclosing `step()` an Action Step. 81 | * @public 82 | * @deprecated Use `action(name, f)` or `action` template tag instead. 83 | */ 84 | export function action(f: ActionFunction): void 85 | 86 | // Implementation 87 | export function action(...args) { 88 | return getInstance().action(...args) 89 | } 90 | 91 | /** 92 | * Creates a Deferred Action Step, for, e.g., cleaning up resources. 93 | * @public 94 | */ 95 | export function defer(name: string, f: ActionFunction): void 96 | 97 | /** 98 | * Creates a Deferred Action Step, for, e.g., cleaning up resources. 99 | * @public 100 | */ 101 | export function defer( 102 | nameParts: TemplateStringsArray, 103 | ...substitutions: any[] 104 | ): (f?: ActionFunction) => void 105 | 106 | /** 107 | * Creates a Deferred Action Step, for, e.g., cleaning up resources. 108 | */ 109 | export function defer(...args) { 110 | return getInstance().defer(...args) 111 | } 112 | 113 | /** 114 | * Creates a Pending step to make the test end with pending state. 115 | * Useful for unfinished tests. 116 | * @public 117 | */ 118 | export function pending(): void { 119 | getInstance().pending() 120 | } 121 | 122 | /** 123 | * Marks the steps inside as independent 124 | * @public 125 | */ 126 | export function independent(f: () => X): X { 127 | return getInstance().independent(f) 128 | } 129 | 130 | /** 131 | * Deprecated. 132 | * @public 133 | * @deprecated Use `to()` instead. 134 | */ 135 | export function step(name: StepDefName, f: () => X): X 136 | 137 | /** 138 | * Deprecated. 139 | * @public 140 | * @deprecated Use `to()` instead. 141 | */ 142 | export function step(...args) { 143 | return getInstance().step(...args) 144 | } 145 | 146 | /** 147 | * Deprecated. 148 | * @public 149 | * @deprecated Use `defer()` instead. 150 | */ 151 | export function cleanup(name: StepDefName, f: () => X): X 152 | 153 | // Implementation 154 | export function cleanup(...args) { 155 | return getInstance().cleanup(...args) 156 | } 157 | 158 | /** 159 | * Deprecated. 160 | * @public 161 | * @deprecated Use `defer()` instead. 162 | */ 163 | export function onFinish(f: () => void): void 164 | 165 | // Implementation 166 | export function onFinish(...args) { 167 | return getInstance().onFinish(...args) 168 | } 169 | 170 | /** 171 | * Returns the current state object. 172 | * This allows library functions to hook into prescript’s state. 173 | * @public 174 | */ 175 | export function getCurrentState() { 176 | if (!currentActionContext.current) { 177 | throw new Error('getCurrentState() must be called inside an action.') 178 | } 179 | return currentActionContext.current.state 180 | } 181 | 182 | /** 183 | * Returns the current action context object. 184 | * This allows library functions to hook into prescript’s current action context. 185 | * @public 186 | */ 187 | export function getCurrentContext() { 188 | if (!currentActionContext.current) { 189 | throw new Error('getCurrentContext() must be called inside an action.') 190 | } 191 | return currentActionContext.current.context 192 | } 193 | 194 | const stateCache = new WeakMap() 195 | 196 | /** 197 | * Returns a state object that exists only during prescription phase for each test. 198 | * @public 199 | */ 200 | export function getCurrentPrescriptionState() { 201 | const instance = getInstance() 202 | if (stateCache.has(instance)) return stateCache.get(instance)! 203 | const state: Prescript.PrescriptionState = {} 204 | stateCache.set(instance, state) 205 | return state 206 | } 207 | 208 | export default { 209 | test, 210 | to, 211 | action, 212 | defer, 213 | pending, 214 | step, 215 | cleanup, 216 | onFinish, 217 | getCurrentState, 218 | getCurrentContext, 219 | getCurrentPrescriptionState, 220 | isPendingError 221 | } 222 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { StepName } from './StepName' 2 | 3 | declare global { 4 | namespace Prescript { 5 | interface GlobalState { 6 | [key: string]: unknown 7 | } 8 | interface PrescriptionState { 9 | [key: string]: unknown 10 | } 11 | } 12 | } 13 | 14 | /** 15 | * @public 16 | */ 17 | export type StepDefName = StepName | string 18 | 19 | /** 20 | * Configuration defined in the `prescript.config.js` file. 21 | * For more information, see the {@link https://taskworld.github.io/prescript/guide/config.html | advanced configuration guide}. 22 | * @public 23 | */ 24 | export interface IConfig { 25 | /** 26 | * You can setup an action wrapper that will wrap all action steps. It is like a middleware. 27 | * 28 | * @remarks 29 | * It can be used for various purposes: 30 | * - Enhance the error message / stack trace. 31 | * - Benchmarking and profiling. 32 | * - etc. 33 | * 34 | * @alpha 35 | */ 36 | wrapAction?: ActionWrapper 37 | 38 | /** 39 | * Create a custom test reporter. 40 | * @remarks 41 | * It is very important that the reporter do not throw an error. 42 | * Otherwise, the behavior of prescript is undefined. 43 | * @param testModulePath - The path of the test file. 44 | * @alpha 45 | */ 46 | createTestReporter?(testModulePath: string, testName: string): ITestReporter 47 | } 48 | 49 | /** 50 | * @alpha 51 | */ 52 | export type ActionWrapper = ( 53 | step: IStep, 54 | execute: () => Promise, 55 | state: Prescript.GlobalState, 56 | context: ITestExecutionContext 57 | ) => Promise 58 | 59 | /** 60 | * @alpha 61 | */ 62 | export interface IStep { 63 | name: StepName 64 | number?: string 65 | children?: IStep[] 66 | creator?: string 67 | definition?: string 68 | independent?: boolean 69 | 70 | action?: ActionFunction 71 | actionDefinition?: string 72 | 73 | pending?: boolean 74 | cleanup?: boolean 75 | defer?: boolean 76 | } 77 | 78 | export interface ITestLoadLogger { 79 | step(step: IStep): void 80 | test(name: StepName): void 81 | } 82 | 83 | // Note: Keep this interface in sync with `singletonApi.ts` exports! 84 | export interface IPrescriptAPI { 85 | test(name: string, f: () => X): X 86 | test( 87 | nameParts: TemplateStringsArray, 88 | ...substitutions: any[] 89 | ): (f: () => X) => X 90 | 91 | to(name: string, f: () => X): X 92 | to( 93 | nameParts: TemplateStringsArray, 94 | ...substitutions: any[] 95 | ): (f: () => X) => X 96 | 97 | action(name: string, f: ActionFunction): void 98 | action( 99 | nameParts: TemplateStringsArray, 100 | ...substitutions: any[] 101 | ): (f: ActionFunction) => void 102 | action(f: ActionFunction): void 103 | 104 | defer(name: string, f: ActionFunction): void 105 | defer( 106 | nameParts: TemplateStringsArray, 107 | ...substitutions: any[] 108 | ): (f: ActionFunction) => void 109 | 110 | pending(): void 111 | 112 | step(name: StepDefName, f: () => X): X 113 | 114 | cleanup(name: StepDefName, f: () => X): X 115 | 116 | onFinish(f: () => void): void 117 | 118 | independent(f: () => X): X 119 | 120 | // This is only in IPrescriptAPI 121 | use(f: (api: IPrescriptAPI) => void): void 122 | } 123 | 124 | /** 125 | * @public 126 | */ 127 | export interface ITestExecutionContext { 128 | /** 129 | * This adds a log message to the current step. 130 | * API is the same as `console.log()`. 131 | * Use this function instead of `console.log()` to not clutter the console output. 132 | * @param format - Format string, like `console.log()` 133 | * @param args - Arguments to be formatted. 134 | */ 135 | log(format: any, ...args: any[]): void 136 | 137 | /** 138 | * This adds an attachment to the current step, such as screenshot, JSON result, etc. 139 | * @param name - Name of the attachment 140 | * @param buffer - Attachment content 141 | * @param mimeType - MIME type of the attachment (image/jpeg, text/plain, application/json...) 142 | */ 143 | attach(name: string, buffer: Buffer, mimeType: string): void 144 | } 145 | 146 | export interface IIterationListener { 147 | onEnter: (node: IStep) => void 148 | onExit: (node: IStep, error?: Error) => void 149 | } 150 | 151 | /** 152 | * @alpha 153 | */ 154 | export interface ITestReporter { 155 | /** 156 | * Called when the test is finished. 157 | * @param errors - Errors that occurred during the test. 158 | * If there are no errors, this will be an empty array. 159 | * Note that pending tests are treated the same way as errors. 160 | * To check if an error object represents a pending test, use the {@link isPendingError} function. 161 | */ 162 | onFinish(errors: Error[]): void 163 | 164 | /** 165 | * Called when the test step is being entered. 166 | * @param step - The test step that is being entered. 167 | */ 168 | onEnterStep(step: IStep): void 169 | 170 | /** 171 | * Called when the test step is being exited. 172 | * @param step - The test step that is being exited. 173 | * @param error - The error that occurred during the test step. 174 | */ 175 | onExitStep(step: IStep, error?: Error): void 176 | } 177 | 178 | export interface IVisitor { 179 | visitNode(node: IStep) 180 | visitDeferNode(node: IStep) 181 | } 182 | 183 | export interface ITestIterator { 184 | setTest(test: IStep) 185 | getTest(): IStep 186 | begin(beginningStep?: string | null) 187 | getCurrentStepNumber(): string | null 188 | getCurrentStep(): IStep 189 | isDone(): boolean 190 | actionPassed(): void 191 | actionFailed(error: Error): void 192 | } 193 | 194 | /** 195 | * @public 196 | */ 197 | export interface Thenable { 198 | then( 199 | onFulfilled?: ((value: any) => any) | undefined | null, 200 | onRejected?: ((reason: any) => any) | undefined | null 201 | ): Thenable 202 | } 203 | 204 | /** 205 | * @public 206 | */ 207 | export type ActionFunction = ( 208 | state: Prescript.GlobalState, 209 | context: ITestExecutionContext 210 | ) => void | Thenable 211 | -------------------------------------------------------------------------------- /src/walkSteps.ts: -------------------------------------------------------------------------------- 1 | import { IStep } from './types' 2 | 3 | export default function walkSteps(root: IStep, visit: (node: IStep) => void) { 4 | const traverse = node => { 5 | visit(node) 6 | if (node.children) for (const child of node.children) traverse(child) 7 | } 8 | for (const child of root.children || []) traverse(child) 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "esModuleInterop": true, 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": true, 10 | "outDir": "lib", 11 | "rootDir": "src", 12 | "skipLibCheck": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "target": "es2017", 16 | "useUnknownInCatchVariables": false 17 | }, 18 | "include": ["src/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /types-test/types-test.ts: -------------------------------------------------------------------------------- 1 | import * as singletonApi from '../src/singletonApi' 2 | import { IPrescriptAPI } from '../src/types' 3 | 4 | type PublicAPI = Pick< 5 | typeof singletonApi, 6 | Exclude 7 | > 8 | 9 | // This asserts that the internal IPrescriptAPI is kept in sync with PublicAPI 10 | let x: PublicAPI = (null as any) as IPrescriptAPI 11 | void x 12 | --------------------------------------------------------------------------------